Skip to main content

material_ui_rs/widget/component/
checkbox.rs

1//! Material 3 checkbox constructors with token-backed size and motion defaults.
2
3use super::*;
4
5type StyleFn<'a> = Box<dyn Fn(&Theme, iced_checkbox::Status) -> iced_checkbox::Style + 'a>;
6
7/// A Material 3 checkbox with animated selected state transitions.
8pub struct Checkbox<'a, Message, Renderer = iced_widget::Renderer>
9where
10    Renderer: core_text::Renderer,
11{
12    is_checked: bool,
13    on_toggle: Option<Box<dyn Fn(bool) -> Message + 'a>>,
14    label: Option<text::Fragment<'a>>,
15    width: Length,
16    size: f32,
17    spacing: f32,
18    text_size: Option<Pixels>,
19    text_line_height: LineHeight,
20    text_shaping: text::Shaping,
21    text_wrapping: text::Wrapping,
22    font: Option<Renderer::Font>,
23    style: StyleFn<'a>,
24    content_alpha: f32,
25}
26
27impl<Message, Renderer> std::fmt::Debug for Checkbox<'_, Message, Renderer>
28where
29    Renderer: core_text::Renderer,
30{
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("Checkbox")
33            .field("is_checked", &self.is_checked)
34            .field("has_on_toggle", &self.on_toggle.is_some())
35            .field("has_label", &self.label.is_some())
36            .field("width", &self.width)
37            .field("size", &self.size)
38            .field("spacing", &self.spacing)
39            .field("text_size", &self.text_size)
40            .field("text_line_height", &self.text_line_height)
41            .field("content_alpha", &self.content_alpha)
42            .finish_non_exhaustive()
43    }
44}
45
46impl<'a, Message, Renderer> Checkbox<'a, Message, Renderer>
47where
48    Renderer: core_text::Renderer,
49{
50    pub fn new(is_checked: bool) -> Self {
51        Self {
52            is_checked,
53            on_toggle: None,
54            label: None,
55            width: Length::Shrink,
56            size: tokens::component::checkbox::CONTAINER_SIZE,
57            spacing: f32::from(tokens::component::divider::LIST_ITEM_LEADING_SPACE),
58            text_size: Some(Pixels(tokens::component::checkbox::LABEL_TEXT_SIZE)),
59            text_line_height: absolute_line_height(
60                tokens::component::checkbox::LABEL_TEXT_LINE_HEIGHT,
61            ),
62            text_shaping: text::Shaping::default(),
63            text_wrapping: text::Wrapping::default(),
64            font: None,
65            style: Box::new(checkbox_style::default),
66            content_alpha: 1.0,
67        }
68    }
69
70    pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
71        self.label = Some(label.into_fragment());
72        self
73    }
74
75    pub fn on_toggle(mut self, on_toggle: impl Fn(bool) -> Message + 'a) -> Self {
76        self.on_toggle = Some(Box::new(on_toggle));
77        self
78    }
79
80    pub fn on_toggle_maybe(mut self, on_toggle: Option<impl Fn(bool) -> Message + 'a>) -> Self {
81        self.on_toggle = on_toggle.map(|on_toggle| Box::new(on_toggle) as _);
82        self
83    }
84
85    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
86        self.size = size.into().0;
87        self
88    }
89
90    pub fn width(mut self, width: impl Into<Length>) -> Self {
91        self.width = width.into();
92        self
93    }
94
95    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
96        self.spacing = spacing.into().0;
97        self
98    }
99
100    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
101        self.text_size = Some(text_size.into());
102        self
103    }
104
105    pub fn text_line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
106        self.text_line_height = line_height.into();
107        self
108    }
109
110    pub fn text_shaping(mut self, shaping: text::Shaping) -> Self {
111        self.text_shaping = shaping;
112        self
113    }
114
115    pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self {
116        self.text_wrapping = wrapping;
117        self
118    }
119
120    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
121        self.font = Some(font.into());
122        self
123    }
124
125    pub fn style(
126        mut self,
127        style: impl Fn(&Theme, iced_checkbox::Status) -> iced_checkbox::Style + 'a,
128    ) -> Self {
129        self.style = Box::new(style);
130        self
131    }
132
133    pub fn alpha(mut self, content_alpha: f32) -> Self {
134        self.content_alpha = content_alpha.clamp(0.0, 1.0);
135        self
136    }
137}
138
139fn checkbox_style_alpha(mut style: iced_checkbox::Style, alpha: f32) -> iced_checkbox::Style {
140    style.background = style.background.scale_alpha(alpha);
141    style.icon_color = alpha_color(style.icon_color, alpha);
142    style.border = alpha_border(style.border, alpha);
143    style.text_color = style.text_color.map(|color| alpha_color(color, alpha));
144    style
145}
146
147impl<Message, Renderer> Checkbox<'_, Message, Renderer>
148where
149    Renderer: core_text::Renderer,
150{
151    fn current_status(&self, bounds: Rectangle, cursor: mouse::Cursor) -> iced_checkbox::Status {
152        if self.on_toggle.is_none() {
153            iced_checkbox::Status::Disabled {
154                is_checked: self.is_checked,
155            }
156        } else if cursor.is_over(bounds) {
157            iced_checkbox::Status::Hovered {
158                is_checked: self.is_checked,
159            }
160        } else {
161            iced_checkbox::Status::Active {
162                is_checked: self.is_checked,
163            }
164        }
165    }
166
167    fn state_layer_color(
168        &self,
169        state: &SelectionState<Renderer::Paragraph, iced_checkbox::Status>,
170        status: iced_checkbox::Status,
171        unchecked_style: &iced_checkbox::Style,
172        checked_style: &iced_checkbox::Style,
173    ) -> Option<Color> {
174        let is_hovered = matches!(status, iced_checkbox::Status::Hovered { .. });
175
176        if !state.is_pressed && !is_hovered {
177            return None;
178        }
179
180        let color = if self.is_checked {
181            solid_color(checked_style.background)
182        } else {
183            unchecked_style.border.color
184        };
185        let opacity = if state.is_pressed {
186            tokens::state::PRESSED_STATE_LAYER_OPACITY
187        } else {
188            tokens::state::HOVER_STATE_LAYER_OPACITY
189        };
190
191        Some(alpha_color(color, opacity))
192    }
193}
194
195impl<Message, Renderer> Widget<Message, Theme, Renderer> for Checkbox<'_, Message, Renderer>
196where
197    Renderer: core_text::Renderer + core_svg::Renderer,
198{
199    fn tag(&self) -> tree::Tag {
200        tree::Tag::of::<SelectionState<Renderer::Paragraph, iced_checkbox::Status>>()
201    }
202
203    fn state(&self) -> tree::State {
204        tree::State::new(
205            SelectionState::<Renderer::Paragraph, iced_checkbox::Status>::new(self.is_checked),
206        )
207    }
208
209    fn size(&self) -> Size<Length> {
210        Size {
211            width: self.width,
212            height: Length::Shrink,
213        }
214    }
215
216    fn layout(
217        &mut self,
218        tree: &mut Tree,
219        renderer: &Renderer,
220        limits: &layout::Limits,
221    ) -> layout::Node {
222        layout::next_to_each_other(
223            &limits.width(self.width),
224            if self.label.is_some() {
225                self.spacing
226            } else {
227                0.0
228            },
229            |_| layout::Node::new(Size::new(self.size, self.size)),
230            |limits| {
231                if let Some(label) = self.label.as_deref() {
232                    let state = tree
233                        .state
234                        .downcast_mut::<SelectionState<Renderer::Paragraph, iced_checkbox::Status>>(
235                        );
236
237                    core_widget::text::layout(
238                        &mut state.text,
239                        renderer,
240                        limits,
241                        label,
242                        core_widget::text::Format {
243                            width: self.width,
244                            height: Length::Shrink,
245                            line_height: self.text_line_height,
246                            size: self.text_size,
247                            font: self.font,
248                            align_x: text::Alignment::Default,
249                            align_y: alignment::Vertical::Top,
250                            shaping: self.text_shaping,
251                            wrapping: self.text_wrapping,
252                        },
253                    )
254                } else {
255                    layout::Node::new(Size::ZERO)
256                }
257            },
258        )
259    }
260
261    fn update(
262        &mut self,
263        tree: &mut Tree,
264        event: &Event,
265        layout: Layout<'_>,
266        cursor: mouse::Cursor,
267        _renderer: &Renderer,
268        _clipboard: &mut dyn Clipboard,
269        shell: &mut Shell<'_, Message>,
270        _viewport: &Rectangle,
271    ) {
272        let state = tree
273            .state
274            .downcast_mut::<SelectionState<Renderer::Paragraph, iced_checkbox::Status>>();
275        let hit_bounds =
276            selection_control_hit_bounds(layout, tokens::component::checkbox::STATE_LAYER_SIZE);
277
278        match event {
279            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
280            | Event::Touch(touch::Event::FingerPressed { .. })
281                if self.on_toggle.is_some() && press_is_over(event, hit_bounds, cursor) =>
282            {
283                state.is_pressed = true;
284                state.press_origin = None;
285                shell.capture_event();
286                shell.request_redraw();
287            }
288            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
289            | Event::Touch(touch::Event::FingerLifted { .. })
290                if state.is_pressed =>
291            {
292                let is_released_over = release_is_over(event, hit_bounds, cursor);
293
294                state.is_pressed = false;
295                state.press_origin = None;
296
297                if is_released_over && let Some(on_toggle) = &self.on_toggle {
298                    shell.publish((on_toggle)(!self.is_checked));
299                }
300
301                shell.capture_event();
302                shell.request_redraw();
303            }
304            Event::Touch(touch::Event::FingerLost { .. }) if state.is_pressed => {
305                state.is_pressed = false;
306                state.press_origin = None;
307                shell.request_redraw();
308            }
309            _ => {}
310        }
311
312        let now = match event {
313            Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
314            _ => None,
315        };
316
317        if state.target != self.is_checked {
318            let now = now.unwrap_or_else(Instant::now);
319            let (duration, easing) = if self.is_checked {
320                (
321                    duration_ms(tokens::component::checkbox::SELECT_TRANSITION_DURATION_MS),
322                    tokens::component::checkbox::SELECT_TRANSITION_EASING,
323                )
324            } else {
325                (
326                    duration_ms(tokens::component::checkbox::UNSELECT_TRANSITION_DURATION_MS),
327                    tokens::component::checkbox::UNSELECT_TRANSITION_EASING,
328                )
329            };
330
331            state.target = self.is_checked;
332            state
333                .position
334                .set_target(bool_value(self.is_checked), now, duration, easing);
335            state.color.set_target(
336                bool_value(self.is_checked),
337                now,
338                duration_ms(tokens::component::checkbox::OPACITY_TRANSITION_DURATION_MS),
339                tokens::motion::EASING_LINEAR,
340            );
341            state
342                .size
343                .set_target(bool_value(self.is_checked), now, duration, easing);
344            shell.request_redraw();
345        }
346
347        let current_status = self.current_status(hit_bounds, cursor);
348
349        if let Some(now) = now {
350            if state.advance(now) {
351                shell.request_redraw();
352            }
353
354            state.last_status = Some(current_status);
355        } else if state
356            .last_status
357            .is_some_and(|status| status != current_status)
358            || state.is_animating()
359        {
360            shell.request_redraw();
361        }
362    }
363
364    fn mouse_interaction(
365        &self,
366        _tree: &Tree,
367        layout: Layout<'_>,
368        cursor: mouse::Cursor,
369        _viewport: &Rectangle,
370        _renderer: &Renderer,
371    ) -> mouse::Interaction {
372        let hit_bounds =
373            selection_control_hit_bounds(layout, tokens::component::checkbox::STATE_LAYER_SIZE);
374
375        if cursor.is_over(hit_bounds) && self.on_toggle.is_some() {
376            mouse::Interaction::Pointer
377        } else {
378            mouse::Interaction::default()
379        }
380    }
381
382    fn draw(
383        &self,
384        tree: &Tree,
385        renderer: &mut Renderer,
386        theme: &Theme,
387        defaults: &renderer::Style,
388        layout: Layout<'_>,
389        _cursor: mouse::Cursor,
390        viewport: &Rectangle,
391    ) {
392        let state = tree
393            .state
394            .downcast_ref::<SelectionState<Renderer::Paragraph, iced_checkbox::Status>>();
395
396        let mut children = layout.children();
397        let control_layout = children.next().unwrap();
398        let bounds = control_layout.bounds();
399
400        let status = state
401            .last_status
402            .unwrap_or(iced_checkbox::Status::Disabled {
403                is_checked: self.is_checked,
404            });
405        let unchecked_status = match status {
406            iced_checkbox::Status::Active { .. } => {
407                iced_checkbox::Status::Active { is_checked: false }
408            }
409            iced_checkbox::Status::Hovered { .. } => {
410                iced_checkbox::Status::Hovered { is_checked: false }
411            }
412            iced_checkbox::Status::Disabled { .. } => {
413                iced_checkbox::Status::Disabled { is_checked: false }
414            }
415        };
416        let checked_status = match status {
417            iced_checkbox::Status::Active { .. } => {
418                iced_checkbox::Status::Active { is_checked: true }
419            }
420            iced_checkbox::Status::Hovered { .. } => {
421                iced_checkbox::Status::Hovered { is_checked: true }
422            }
423            iced_checkbox::Status::Disabled { .. } => {
424                iced_checkbox::Status::Disabled { is_checked: true }
425            }
426        };
427        let current_style = checkbox_style_alpha((self.style)(theme, status), self.content_alpha);
428        let unchecked_style =
429            checkbox_style_alpha((self.style)(theme, unchecked_status), self.content_alpha);
430        let checked_style =
431            checkbox_style_alpha((self.style)(theme, checked_status), self.content_alpha);
432
433        let selection = state.position.value.clamp(0.0, 1.0);
434        let opacity = state.color.value.clamp(0.0, 1.0);
435        let scale = 0.74 + 0.26 * state.size.value.clamp(0.0, 1.0);
436
437        if let Some(layer_color) =
438            self.state_layer_color(state, status, &unchecked_style, &checked_style)
439        {
440            renderer.fill_quad(
441                renderer::Quad {
442                    bounds: scaled_rect(
443                        bounds,
444                        tokens::component::checkbox::STATE_LAYER_SIZE,
445                        tokens::component::checkbox::STATE_LAYER_SIZE,
446                    ),
447                    border: border::rounded(tokens::component::checkbox::STATE_LAYER_SIZE / 2.0),
448                    ..renderer::Quad::default()
449                },
450                Background::Color(layer_color),
451            );
452        }
453
454        renderer.fill_quad(
455            renderer::Quad {
456                bounds,
457                border: alpha_border(unchecked_style.border, 1.0 - selection),
458                ..renderer::Quad::default()
459            },
460            unchecked_style.background.scale_alpha(1.0 - selection),
461        );
462
463        if selection > 0.0 {
464            let selected_bounds = scaled_rect(bounds, bounds.width * scale, bounds.height * scale);
465
466            renderer.fill_quad(
467                renderer::Quad {
468                    bounds: selected_bounds,
469                    border: alpha_border(checked_style.border, selection),
470                    ..renderer::Quad::default()
471                },
472                checked_style.background.scale_alpha(selection),
473            );
474        }
475
476        if opacity > 0.0 {
477            let icon_scale = 0.58 + 0.42 * selection;
478            let icon_size = tokens::component::checkbox::ICON_SIZE * icon_scale;
479            let mark_progress = if self.is_checked { selection } else { 1.0 };
480
481            renderer.draw_svg(
482                core_svg::Svg::new(core_svg::Handle::from_memory(checkbox_checkmark_svg(
483                    mark_progress,
484                )))
485                .color(checked_style.icon_color)
486                .opacity(opacity),
487                scaled_rect(bounds, icon_size, icon_size),
488                *viewport,
489            );
490        }
491
492        if self.label.is_none() {
493            return;
494        }
495
496        let label_layout = children.next().unwrap();
497
498        core_widget::text::draw(
499            renderer,
500            defaults,
501            label_layout.bounds(),
502            state.text.raw(),
503            core_widget::text::Style {
504                color: current_style.text_color,
505            },
506            viewport,
507        );
508    }
509
510    fn operate(
511        &mut self,
512        _tree: &mut Tree,
513        layout: Layout<'_>,
514        _renderer: &Renderer,
515        operation: &mut dyn core_widget::Operation,
516    ) {
517        if let Some(label) = self.label.as_deref() {
518            operation.text(None, layout.bounds(), label);
519        }
520    }
521}
522
523impl<'a, Message, Renderer> From<Checkbox<'a, Message, Renderer>>
524    for Element<'a, Message, Theme, Renderer>
525where
526    Message: 'a,
527    Renderer: core_text::Renderer + core_svg::Renderer + 'a,
528{
529    fn from(checkbox: Checkbox<'a, Message, Renderer>) -> Self {
530        Element::new(checkbox)
531    }
532}
533
534pub fn control<'a, Message, Renderer>(is_checked: bool) -> Checkbox<'a, Message, Renderer>
535where
536    Renderer: core_text::Renderer + core_svg::Renderer + 'a,
537{
538    Checkbox::new(is_checked)
539        .size(tokens::component::checkbox::CONTAINER_SIZE)
540        .spacing(f32::from(
541            tokens::component::divider::LIST_ITEM_LEADING_SPACE,
542        ))
543        .text_size(tokens::component::checkbox::LABEL_TEXT_SIZE)
544        .text_line_height(absolute_line_height(
545            tokens::component::checkbox::LABEL_TEXT_LINE_HEIGHT,
546        ))
547        .style(checkbox_style::default)
548}
549
550pub fn standard<'a, Message, Renderer>(
551    is_checked: bool,
552    label: impl text::IntoFragment<'a>,
553    on_toggle: impl Fn(bool) -> Message + 'a,
554) -> Element<'a, Message, Theme, Renderer>
555where
556    Message: 'a,
557    Renderer: iced_widget::core::Renderer + core_text::Renderer + core_svg::Renderer + 'a,
558{
559    standard_with_alpha(is_checked, label, on_toggle, 1.0)
560}
561
562pub fn standard_with_alpha<'a, Message, Renderer>(
563    is_checked: bool,
564    label: impl text::IntoFragment<'a>,
565    on_toggle: impl Fn(bool) -> Message + 'a,
566    alpha: f32,
567) -> Element<'a, Message, Theme, Renderer>
568where
569    Message: 'a,
570    Renderer: iced_widget::core::Renderer + core_text::Renderer + core_svg::Renderer + 'a,
571{
572    Container::new(
573        control(is_checked)
574            .label(label)
575            .on_toggle(on_toggle)
576            .alpha(alpha),
577    )
578    .center_y(Length::Fixed(tokens::component::checkbox::STATE_LAYER_SIZE))
579    .into()
580}
581
582#[cfg(test)]
583#[path = "../../../tests/widget/component/checkbox.rs"]
584mod tests;