Skip to main content

egui_material3/
checkbox.rs

1use crate::get_global_color;
2use egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget};
3
4/// Material Design checkbox component following Material Design 3 specifications
5///
6/// Provides a checkbox with three states: checked, unchecked, and indeterminate.
7/// Follows Material Design guidelines for colors, sizing, and interaction states.
8///
9/// ## Usage Examples
10/// ```rust
11/// # egui::__run_test_ui(|ui| {
12/// let mut checked = false;
13///
14/// // Basic checkbox
15/// ui.add(MaterialCheckbox::new(&mut checked, "Accept terms"));
16///
17/// // Checkbox with indeterminate state
18/// let mut partial_checked = false;
19/// ui.add(MaterialCheckbox::new(&mut partial_checked, "Select all")
20///     .indeterminate(true));
21///
22/// // Disabled checkbox
23/// let mut disabled_checked = false;  
24/// ui.add(MaterialCheckbox::new(&mut disabled_checked, "Disabled option")
25///     .enabled(false));
26/// # });
27/// ```
28///
29/// ## Material Design Spec
30/// - Size: 18x18dp checkbox with 40x40dp touch target
31/// - Colors: Primary color when checked, outline when unchecked
32/// - Animation: 150ms cubic-bezier transition
33/// - States: Normal, hover, focus, pressed, disabled, error
34pub struct MaterialCheckbox<'a> {
35    /// Mutable reference to the checked state
36    checked: &'a mut bool,
37    /// Text label displayed next to the checkbox
38    text: String,
39    /// Whether the checkbox is in indeterminate state (partially checked)
40    indeterminate: bool,
41    /// Whether the checkbox is interactive (enabled/disabled)
42    enabled: bool,
43    /// Whether to show error state styling
44    is_error: bool,
45    /// Custom check mark color (overrides theme)
46    check_color: Option<Color32>,
47    /// Custom fill color when checked (overrides theme)
48    fill_color: Option<Color32>,
49    /// Custom border width (default: 2.0)
50    border_width: f32,
51}
52
53impl<'a> MaterialCheckbox<'a> {
54    /// Create a new Material Design checkbox
55    ///
56    /// ## Parameters
57    /// - `checked`: Mutable reference to boolean state
58    /// - `text`: Label text displayed next to checkbox
59    ///
60    /// ## Returns
61    /// A new MaterialCheckbox instance with default settings
62    pub fn new(checked: &'a mut bool, text: impl Into<String>) -> Self {
63        Self {
64            checked,
65            text: text.into(),
66            indeterminate: false,
67            enabled: true,
68            is_error: false,
69            check_color: None,
70            fill_color: None,
71            border_width: 2.0,
72        }
73    }
74
75    /// Set the indeterminate state of the checkbox
76    ///
77    /// Indeterminate checkboxes are used when the checkbox represents
78    /// a collection of items where some, but not all, are selected.
79    ///
80    /// ## Parameters  
81    /// - `indeterminate`: True for indeterminate state, false for normal
82    pub fn indeterminate(mut self, indeterminate: bool) -> Self {
83        self.indeterminate = indeterminate;
84        self
85    }
86
87    /// Set whether the checkbox is enabled or disabled
88    ///
89    /// Disabled checkboxes cannot be interacted with and are visually dimmed.
90    ///
91    /// ## Parameters
92    /// - `enabled`: True for interactive, false for disabled
93    pub fn enabled(mut self, enabled: bool) -> Self {
94        self.enabled = enabled;
95        self
96    }
97
98    /// Set whether the checkbox should display in error state
99    ///
100    /// Error state checkboxes use error color from the theme to indicate
101    /// validation failure or invalid selection.
102    ///
103    /// ## Parameters
104    /// - `is_error`: True for error state styling
105    pub fn is_error(mut self, is_error: bool) -> Self {
106        self.is_error = is_error;
107        self
108    }
109
110    /// Set custom check mark color
111    ///
112    /// Overrides the default check mark color from the theme.
113    ///
114    /// ## Parameters
115    /// - `color`: Custom color for the check mark
116    pub fn check_color(mut self, color: Color32) -> Self {
117        self.check_color = Some(color);
118        self
119    }
120
121    /// Set custom fill color when checked
122    ///
123    /// Overrides the default fill color from the theme.
124    ///
125    /// ## Parameters
126    /// - `color`: Custom fill color when checkbox is checked
127    pub fn fill_color(mut self, color: Color32) -> Self {
128        self.fill_color = Some(color);
129        self
130    }
131
132    /// Set custom border width
133    ///
134    /// ## Parameters
135    /// - `width`: Border width in pixels (default: 2.0)
136    pub fn border_width(mut self, width: f32) -> Self {
137        self.border_width = width;
138        self
139    }
140}
141
142impl<'a> Widget for MaterialCheckbox<'a> {
143    fn ui(self, ui: &mut Ui) -> Response {
144        let checkbox_size = 18.0;
145        let spacing = 4.0;
146
147        // Calculate actual width needed: checkbox + spacing + text width
148        let text_width = if !self.text.is_empty() {
149            let font_id = ui.style().text_styles.get(&egui::TextStyle::Body)
150                .cloned()
151                .unwrap_or_else(|| egui::FontId::default());
152            let galley = ui.painter().layout_no_wrap(self.text.clone(), font_id, egui::Color32::WHITE);
153            galley.size().x
154        } else {
155            0.0
156        };
157
158        let desired_width = checkbox_size + spacing + text_width;
159        let desired_size = Vec2::new(desired_width, 24.0);
160
161        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
162
163        if response.clicked() && self.enabled {
164            if self.indeterminate {
165                *self.checked = true;
166            } else {
167                *self.checked = !*self.checked;
168            }
169            response.mark_changed();
170        }
171
172        let _visuals = ui.style().interact(&response);
173        let checkbox_rect = Rect::from_min_size(
174            Pos2::new(rect.min.x, rect.center().y - checkbox_size / 2.0),
175            Vec2::splat(checkbox_size),
176        );
177
178        // Material Design colors
179        let primary_color = self.fill_color.unwrap_or_else(|| get_global_color("primary"));
180        let error_color = get_global_color("error");
181        let on_error = get_global_color("onError");
182        let on_surface = get_global_color("onSurface");
183        let on_surface_variant = get_global_color("onSurfaceVariant");
184        let _surface_variant = get_global_color("surfaceVariant");
185        let _outline = get_global_color("outline");
186        let on_primary = self.check_color.unwrap_or_else(|| get_global_color("onPrimary"));
187
188        // Determine colors based on state
189        let (bg_color, border_color, check_color, border_width) = if !self.enabled {
190            // Material Design disabled state: onSurface with 38% opacity
191            let disabled_color = on_surface.gamma_multiply(0.38);
192            if *self.checked || self.indeterminate {
193                (disabled_color, Color32::TRANSPARENT, on_surface.gamma_multiply(0.38), 0.0)
194            } else {
195                (Color32::TRANSPARENT, disabled_color, disabled_color, self.border_width)
196            }
197        } else if self.is_error {
198            // Error state styling
199            if *self.checked || self.indeterminate {
200                (error_color, Color32::TRANSPARENT, on_error, 0.0)
201            } else if response.hovered() {
202                (Color32::TRANSPARENT, error_color, on_surface, self.border_width)
203            } else {
204                (Color32::TRANSPARENT, error_color, on_surface, self.border_width)
205            }
206        } else if *self.checked || self.indeterminate {
207            // Checked/indeterminate state
208            (primary_color, Color32::TRANSPARENT, on_primary, 0.0)
209        } else if response.hovered() {
210            // Hover state for unchecked
211            (Color32::TRANSPARENT, on_surface, on_surface, self.border_width)
212        } else {
213            // Default unchecked state
214            (Color32::TRANSPARENT, on_surface_variant, on_surface, self.border_width)
215        };
216
217        // Draw checkbox background
218        ui.painter().rect_filled(checkbox_rect, 2.0, bg_color);
219
220        // Draw checkbox border (only for unchecked or when needed)
221        if border_width > 0.0 {
222            ui.painter().rect_stroke(
223                checkbox_rect,
224                2.0,
225                Stroke::new(border_width, border_color),
226                egui::epaint::StrokeKind::Outside,
227            );
228        }
229
230        // Draw checkmark or indeterminate mark
231        if *self.checked && !self.indeterminate {
232            // Draw checkmark
233            let center = checkbox_rect.center();
234            let checkmark_size = checkbox_size * 0.6;
235
236            let start = Pos2::new(center.x - checkmark_size * 0.3, center.y);
237            let middle = Pos2::new(
238                center.x - checkmark_size * 0.1,
239                center.y + checkmark_size * 0.2,
240            );
241            let end = Pos2::new(
242                center.x + checkmark_size * 0.3,
243                center.y - checkmark_size * 0.2,
244            );
245
246            ui.painter()
247                .line_segment([start, middle], Stroke::new(2.0, check_color));
248            ui.painter()
249                .line_segment([middle, end], Stroke::new(2.0, check_color));
250        } else if self.indeterminate {
251            // Draw indeterminate mark (horizontal line)
252            let center = checkbox_rect.center();
253            let line_width = checkbox_size * 0.5;
254            let start = Pos2::new(center.x - line_width / 2.0, center.y);
255            let end = Pos2::new(center.x + line_width / 2.0, center.y);
256
257            ui.painter()
258                .line_segment([start, end], Stroke::new(2.0, check_color));
259        }
260
261        // Draw label text
262        if !self.text.is_empty() {
263            let text_pos = Pos2::new(checkbox_rect.max.x + 4.0, rect.center().y);
264
265            let text_color = if self.enabled {
266                on_surface
267            } else {
268                on_surface.gamma_multiply(0.38)
269            };
270
271            ui.painter().text(
272                text_pos,
273                egui::Align2::LEFT_CENTER,
274                &self.text,
275                egui::FontId::default(),
276                text_color,
277            );
278        }
279
280        // Add state overlay effect (hover/focus/pressed)
281        if self.enabled {
282            let overlay_rect = Rect::from_center_size(checkbox_rect.center(), Vec2::splat(40.0));
283            let overlay_color = if response.is_pointer_button_down_on() {
284                // Pressed state: 10% opacity
285                if self.is_error {
286                    Color32::from_rgba_premultiplied(
287                        error_color.r(),
288                        error_color.g(),
289                        error_color.b(),
290                        25,
291                    )
292                } else if *self.checked || self.indeterminate {
293                    Color32::from_rgba_premultiplied(
294                        primary_color.r(),
295                        primary_color.g(),
296                        primary_color.b(),
297                        25,
298                    )
299                } else {
300                    Color32::from_rgba_premultiplied(
301                        on_surface.r(),
302                        on_surface.g(),
303                        on_surface.b(),
304                        25,
305                    )
306                }
307            } else if response.hovered() {
308                // Hover state: 8% opacity
309                if self.is_error {
310                    Color32::from_rgba_premultiplied(
311                        error_color.r(),
312                        error_color.g(),
313                        error_color.b(),
314                        20,
315                    )
316                } else if *self.checked || self.indeterminate {
317                    Color32::from_rgba_premultiplied(
318                        primary_color.r(),
319                        primary_color.g(),
320                        primary_color.b(),
321                        20,
322                    )
323                } else {
324                    Color32::from_rgba_premultiplied(
325                        on_surface.r(),
326                        on_surface.g(),
327                        on_surface.b(),
328                        20,
329                    )
330                }
331            } else if response.has_focus() {
332                // Focus state: 10% opacity
333                if self.is_error {
334                    Color32::from_rgba_premultiplied(
335                        error_color.r(),
336                        error_color.g(),
337                        error_color.b(),
338                        25,
339                    )
340                } else if *self.checked || self.indeterminate {
341                    Color32::from_rgba_premultiplied(
342                        primary_color.r(),
343                        primary_color.g(),
344                        primary_color.b(),
345                        25,
346                    )
347                } else {
348                    Color32::from_rgba_premultiplied(
349                        on_surface.r(),
350                        on_surface.g(),
351                        on_surface.b(),
352                        25,
353                    )
354                }
355            } else {
356                Color32::TRANSPARENT
357            };
358
359            if overlay_color != Color32::TRANSPARENT {
360                ui.painter().circle_filled(
361                    overlay_rect.center(),
362                    overlay_rect.width() / 2.0,
363                    overlay_color,
364                );
365            }
366        }
367
368        response
369    }
370}
371
372pub fn checkbox(checked: &mut bool, text: impl Into<String>) -> MaterialCheckbox<'_> {
373    MaterialCheckbox::new(checked, text)
374}