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