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                    state.is_pressed = true;
267                    state.press_origin = None;
268                    shell.capture_event();
269                    shell.request_redraw();
270                }
271            }
272            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
273            | Event::Touch(touch::Event::FingerLifted { .. }) => {
274                if state.is_pressed {
275                    let is_released_over = release_is_over(event, hit_bounds, cursor);
276
277                    state.is_pressed = false;
278                    state.press_origin = None;
279
280                    if is_released_over && let Some(on_toggle) = &self.on_toggle {
281                        shell.publish((on_toggle)(!self.is_checked));
282                    }
283
284                    shell.capture_event();
285                    shell.request_redraw();
286                }
287            }
288            Event::Touch(touch::Event::FingerLost { .. }) => {
289                if state.is_pressed {
290                    state.is_pressed = false;
291                    state.press_origin = None;
292                    shell.request_redraw();
293                }
294            }
295            _ => {}
296        }
297
298        let now = match event {
299            Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
300            _ => None,
301        };
302
303        if state.target != self.is_checked {
304            let now = now.unwrap_or_else(Instant::now);
305            let (duration, easing) = if self.is_checked {
306                (
307                    duration_ms(tokens::component::checkbox::SELECT_TRANSITION_DURATION_MS),
308                    tokens::component::checkbox::SELECT_TRANSITION_EASING,
309                )
310            } else {
311                (
312                    duration_ms(tokens::component::checkbox::UNSELECT_TRANSITION_DURATION_MS),
313                    tokens::component::checkbox::UNSELECT_TRANSITION_EASING,
314                )
315            };
316
317            state.target = self.is_checked;
318            state
319                .position
320                .set_target(bool_value(self.is_checked), now, duration, easing);
321            state.color.set_target(
322                bool_value(self.is_checked),
323                now,
324                duration_ms(tokens::component::checkbox::OPACITY_TRANSITION_DURATION_MS),
325                tokens::motion::EASING_LINEAR,
326            );
327            state
328                .size
329                .set_target(bool_value(self.is_checked), now, duration, easing);
330            shell.request_redraw();
331        }
332
333        let current_status = self.current_status(hit_bounds, cursor);
334
335        if let Some(now) = now {
336            if state.advance(now) {
337                shell.request_redraw();
338            }
339
340            state.last_status = Some(current_status);
341        } else if state
342            .last_status
343            .is_some_and(|status| status != current_status)
344            || state.is_animating()
345        {
346            shell.request_redraw();
347        }
348    }
349
350    fn mouse_interaction(
351        &self,
352        _tree: &Tree,
353        layout: Layout<'_>,
354        cursor: mouse::Cursor,
355        _viewport: &Rectangle,
356        _renderer: &Renderer,
357    ) -> mouse::Interaction {
358        let hit_bounds =
359            selection_control_hit_bounds(layout, tokens::component::checkbox::STATE_LAYER_SIZE);
360
361        if cursor.is_over(hit_bounds) && self.on_toggle.is_some() {
362            mouse::Interaction::Pointer
363        } else {
364            mouse::Interaction::default()
365        }
366    }
367
368    fn draw(
369        &self,
370        tree: &Tree,
371        renderer: &mut Renderer,
372        theme: &Theme,
373        defaults: &renderer::Style,
374        layout: Layout<'_>,
375        _cursor: mouse::Cursor,
376        viewport: &Rectangle,
377    ) {
378        let state = tree
379            .state
380            .downcast_ref::<SelectionState<Renderer::Paragraph, iced_checkbox::Status>>();
381
382        let mut children = layout.children();
383        let control_layout = children.next().unwrap();
384        let bounds = control_layout.bounds();
385
386        let status = state
387            .last_status
388            .unwrap_or(iced_checkbox::Status::Disabled {
389                is_checked: self.is_checked,
390            });
391        let unchecked_status = match status {
392            iced_checkbox::Status::Active { .. } => {
393                iced_checkbox::Status::Active { is_checked: false }
394            }
395            iced_checkbox::Status::Hovered { .. } => {
396                iced_checkbox::Status::Hovered { is_checked: false }
397            }
398            iced_checkbox::Status::Disabled { .. } => {
399                iced_checkbox::Status::Disabled { is_checked: false }
400            }
401        };
402        let checked_status = match status {
403            iced_checkbox::Status::Active { .. } => {
404                iced_checkbox::Status::Active { is_checked: true }
405            }
406            iced_checkbox::Status::Hovered { .. } => {
407                iced_checkbox::Status::Hovered { is_checked: true }
408            }
409            iced_checkbox::Status::Disabled { .. } => {
410                iced_checkbox::Status::Disabled { is_checked: true }
411            }
412        };
413        let current_style = (self.style)(theme, status);
414        let unchecked_style = (self.style)(theme, unchecked_status);
415        let checked_style = (self.style)(theme, checked_status);
416
417        let selection = state.position.value.clamp(0.0, 1.0);
418        let opacity = state.color.value.clamp(0.0, 1.0);
419        let scale = 0.74 + 0.26 * state.size.value.clamp(0.0, 1.0);
420
421        if let Some(layer_color) =
422            self.state_layer_color(state, status, &unchecked_style, &checked_style)
423        {
424            renderer.fill_quad(
425                renderer::Quad {
426                    bounds: scaled_rect(
427                        bounds,
428                        tokens::component::checkbox::STATE_LAYER_SIZE,
429                        tokens::component::checkbox::STATE_LAYER_SIZE,
430                    ),
431                    border: border::rounded(tokens::component::checkbox::STATE_LAYER_SIZE / 2.0),
432                    ..renderer::Quad::default()
433                },
434                Background::Color(layer_color),
435            );
436        }
437
438        renderer.fill_quad(
439            renderer::Quad {
440                bounds,
441                border: alpha_border(unchecked_style.border, 1.0 - selection),
442                ..renderer::Quad::default()
443            },
444            unchecked_style.background.scale_alpha(1.0 - selection),
445        );
446
447        if selection > 0.0 {
448            let selected_bounds = scaled_rect(bounds, bounds.width * scale, bounds.height * scale);
449
450            renderer.fill_quad(
451                renderer::Quad {
452                    bounds: selected_bounds,
453                    border: alpha_border(checked_style.border, selection),
454                    ..renderer::Quad::default()
455                },
456                checked_style.background.scale_alpha(selection),
457            );
458        }
459
460        if opacity > 0.0 {
461            let icon_scale = 0.58 + 0.42 * selection;
462            let icon_size = tokens::component::checkbox::ICON_SIZE * icon_scale;
463            let mark_progress = if self.is_checked { selection } else { 1.0 };
464
465            renderer.draw_svg(
466                core_svg::Svg::new(core_svg::Handle::from_memory(checkbox_checkmark_svg(
467                    mark_progress,
468                )))
469                .color(checked_style.icon_color)
470                .opacity(opacity),
471                scaled_rect(bounds, icon_size, icon_size),
472                *viewport,
473            );
474        }
475
476        if self.label.is_none() {
477            return;
478        }
479
480        let label_layout = children.next().unwrap();
481
482        core_widget::text::draw(
483            renderer,
484            defaults,
485            label_layout.bounds(),
486            state.text.raw(),
487            core_widget::text::Style {
488                color: current_style.text_color,
489            },
490            viewport,
491        );
492    }
493
494    fn operate(
495        &mut self,
496        _tree: &mut Tree,
497        layout: Layout<'_>,
498        _renderer: &Renderer,
499        operation: &mut dyn core_widget::Operation,
500    ) {
501        if let Some(label) = self.label.as_deref() {
502            operation.text(None, layout.bounds(), label);
503        }
504    }
505}
506
507impl<'a, Message, Renderer> From<Checkbox<'a, Message, Renderer>>
508    for Element<'a, Message, Theme, Renderer>
509where
510    Message: 'a,
511    Renderer: core_text::Renderer + core_svg::Renderer + 'a,
512{
513    fn from(checkbox: Checkbox<'a, Message, Renderer>) -> Self {
514        Element::new(checkbox)
515    }
516}
517
518pub fn control<'a, Message, Renderer>(is_checked: bool) -> Checkbox<'a, Message, Renderer>
519where
520    Renderer: core_text::Renderer + core_svg::Renderer + 'a,
521{
522    Checkbox::new(is_checked)
523        .size(tokens::component::checkbox::CONTAINER_SIZE)
524        .spacing(f32::from(
525            tokens::component::divider::LIST_ITEM_LEADING_SPACE,
526        ))
527        .text_size(tokens::component::checkbox::LABEL_TEXT_SIZE)
528        .text_line_height(absolute_line_height(
529            tokens::component::checkbox::LABEL_TEXT_LINE_HEIGHT,
530        ))
531        .style(checkbox_style::default)
532}
533
534pub fn standard<'a, Message, Renderer>(
535    is_checked: bool,
536    label: impl text::IntoFragment<'a>,
537    on_toggle: impl Fn(bool) -> Message + 'a,
538) -> Element<'a, Message, Theme, Renderer>
539where
540    Message: 'a,
541    Renderer: iced_widget::core::Renderer + core_text::Renderer + core_svg::Renderer + 'a,
542{
543    Container::new(control(is_checked).label(label).on_toggle(on_toggle))
544        .center_y(Length::Fixed(tokens::component::checkbox::STATE_LAYER_SIZE))
545        .into()
546}