Skip to main content

agg_gui/widgets/
checkbox.rs

1//! `Checkbox` — a boolean toggle with a label.
2//!
3//! # Composition
4//!
5//! The checkbox label is rendered through a [`Label`] child with backbuffer
6//! caching enabled (the default).  The box + checkmark are drawn directly via
7//! path commands; only the text goes through the Label path.
8//!
9//! ```text
10//! Checkbox (box + checkmark drawn via paths)
11//!   └── Label (text, backbuffered)
12//! ```
13
14use std::cell::Cell;
15use std::rc::Rc;
16use std::sync::Arc;
17
18use crate::color::Color;
19use crate::draw_ctx::DrawCtx;
20use crate::event::{Event, EventResult, Key, MouseButton};
21use crate::geometry::{Rect, Size};
22use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
23use crate::text::Font;
24use crate::widget::Widget;
25use crate::widgets::label::Label;
26
27const BOX_SIZE: f64 = 16.0;
28const FOCUS_PAD: f64 = 2.0;
29const GAP: f64 = 8.0;
30const BOX_STROKE_WIDTH: f64 = 1.5;
31
32/// Inspector-visible properties of a [`Checkbox`].  See [`SliderProps`] for
33/// the rationale of the companion-props pattern.
34#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
35#[derive(Clone, Debug, Default)]
36pub struct CheckboxProps {
37    pub checked: bool,
38    pub font_size: f64,
39    /// Explicit label colour override; `None` → follow active visuals.
40    pub label_color: Option<Color>,
41}
42
43/// A boolean toggle with a square box and a text label.
44pub struct Checkbox {
45    bounds: Rect,
46    /// `children[0]` is the [`Label`] that renders the text — composed as a
47    /// real child so the framework's paint walk handles it.
48    children: Vec<Box<dyn Widget>>,
49    base: WidgetBase,
50    font: Arc<Font>,
51    pub props: CheckboxProps,
52    /// When set, this cell is the authoritative checked state.  `paint` reads
53    /// from it and `toggle` writes to it so the checkbox stays in sync with
54    /// external state changes (e.g. a window's close button setting it to false).
55    state_cell: Option<Rc<Cell<bool>>>,
56    hovered: bool,
57    focused: bool,
58    on_change: Option<Box<dyn FnMut(bool)>>,
59    /// When set and it returns `true`, the box renders a horizontal dash
60    /// (egui's indeterminate/tri-state look) instead of a checkmark. This only
61    /// affects appearance; clicking still toggles `checked`. Used for
62    /// "check/uncheck all" controls whose state depends on a group of boxes.
63    indeterminate_fn: Option<Box<dyn Fn() -> bool>>,
64    /// Tracked label text — used for empty-check during layout and to rebuild
65    /// the Label child when font size changes.
66    label_text: String,
67}
68
69impl Checkbox {
70    pub fn new(label: impl Into<String>, font: Arc<Font>, checked: bool) -> Self {
71        let label_text: String = label.into();
72        let font_size = 14.0;
73        let label_widget = Label::new(&label_text, Arc::clone(&font)).with_font_size(font_size);
74        Self {
75            bounds: Rect::default(),
76            children: vec![Box::new(label_widget)],
77            base: WidgetBase::new(),
78            font,
79            props: CheckboxProps {
80                checked,
81                font_size,
82                label_color: None,
83            },
84            state_cell: None,
85            hovered: false,
86            focused: false,
87            on_change: None,
88            indeterminate_fn: None,
89            label_text,
90        }
91    }
92
93    pub fn with_font_size(mut self, size: f64) -> Self {
94        self.props.font_size = size;
95        self.children[0] =
96            Box::new(Label::new(&self.label_text, Arc::clone(&self.font)).with_font_size(size));
97        self
98    }
99    pub fn with_label_color(mut self, c: Color) -> Self {
100        self.props.label_color = Some(c);
101        self
102    }
103
104    /// Bind checked state to a shared cell.
105    ///
106    /// When set, `paint` reads from the cell (so external changes — e.g. a
107    /// window's close button — are reflected immediately), and `toggle` writes
108    /// to it so both directions stay in sync.
109    pub fn with_state_cell(mut self, cell: Rc<Cell<bool>>) -> Self {
110        self.state_cell = Some(cell);
111        self
112    }
113
114    pub fn with_margin(mut self, m: Insets) -> Self {
115        self.base.margin = m;
116        self
117    }
118    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
119        self.base.h_anchor = h;
120        self
121    }
122    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
123        self.base.v_anchor = v;
124        self
125    }
126    pub fn with_min_size(mut self, s: Size) -> Self {
127        self.base.min_size = s;
128        self
129    }
130    pub fn with_max_size(mut self, s: Size) -> Self {
131        self.base.max_size = s;
132        self
133    }
134
135    pub fn on_change(mut self, cb: impl FnMut(bool) + 'static) -> Self {
136        self.on_change = Some(Box::new(cb));
137        self
138    }
139
140    /// Drive the indeterminate (tri-state) appearance from a closure.
141    ///
142    /// When the closure returns `true` the box shows a horizontal dash instead
143    /// of a checkmark, mirroring egui's `Checkbox::indeterminate`. The closure
144    /// is evaluated every paint, so it can reflect live aggregate state (e.g. a
145    /// "check all" box that is indeterminate when only some children are set).
146    pub fn with_indeterminate_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
147        self.indeterminate_fn = Some(Box::new(f));
148        self
149    }
150
151    /// Returns whether the box should currently render as indeterminate.
152    #[inline]
153    fn is_indeterminate(&self) -> bool {
154        self.indeterminate_fn.as_ref().is_some_and(|f| f())
155    }
156
157    pub fn checked(&self) -> bool {
158        self.props.checked
159    }
160    pub fn set_checked(&mut self, v: bool) {
161        self.props.checked = v;
162    }
163
164    fn toggle(&mut self) {
165        let new_val = !self.effective_checked();
166        self.props.checked = new_val;
167        if let Some(ref cell) = self.state_cell {
168            cell.set(new_val);
169        }
170        if let Some(cb) = self.on_change.as_mut() {
171            cb(new_val);
172        }
173    }
174
175    /// Returns the authoritative checked state: the cell value if bound, else
176    /// the internal `checked` field.
177    #[inline]
178    fn effective_checked(&self) -> bool {
179        if let Some(ref cell) = self.state_cell {
180            cell.get()
181        } else {
182            self.props.checked
183        }
184    }
185
186    fn unchecked_colors(v: &crate::theme::Visuals, hovered: bool) -> (Color, Color) {
187        let luma = v.bg_color.r * 0.299 + v.bg_color.g * 0.587 + v.bg_color.b * 0.114;
188        if luma < 0.5 {
189            let fill = if hovered { v.widget_bg } else { v.window_fill };
190            (fill, Color::rgba(1.0, 1.0, 1.0, 0.34))
191        } else {
192            let fill = if hovered {
193                v.widget_bg_hovered
194            } else {
195                v.widget_bg
196            };
197            (fill, v.widget_stroke)
198        }
199    }
200}
201
202impl Widget for Checkbox {
203    fn type_name(&self) -> &'static str {
204        "Checkbox"
205    }
206    fn bounds(&self) -> Rect {
207        self.bounds
208    }
209    fn set_bounds(&mut self, b: Rect) {
210        self.bounds = b;
211    }
212    fn children(&self) -> &[Box<dyn Widget>] {
213        &self.children
214    }
215    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
216        &mut self.children
217    }
218
219    #[cfg(feature = "reflect")]
220    fn as_reflect(&self) -> Option<&dyn bevy_reflect::Reflect> {
221        Some(&self.props)
222    }
223    #[cfg(feature = "reflect")]
224    fn as_reflect_mut(&mut self) -> Option<&mut dyn bevy_reflect::Reflect> {
225        Some(&mut self.props)
226    }
227
228    fn is_focusable(&self) -> bool {
229        true
230    }
231
232    fn margin(&self) -> Insets {
233        self.base.margin
234    }
235    fn widget_base(&self) -> Option<&WidgetBase> {
236        Some(&self.base)
237    }
238    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
239        Some(&mut self.base)
240    }
241    fn h_anchor(&self) -> HAnchor {
242        self.base.h_anchor
243    }
244    fn v_anchor(&self) -> VAnchor {
245        self.base.v_anchor
246    }
247    fn min_size(&self) -> Size {
248        self.base.min_size
249    }
250    fn max_size(&self) -> Size {
251        self.base.max_size
252    }
253
254    fn layout(&mut self, available: Size) -> Size {
255        let box_slot_w = BOX_SIZE + FOCUS_PAD * 2.0;
256        let h = (BOX_SIZE + FOCUS_PAD * 2.0).max(self.props.font_size * 1.25);
257        let label_avail_w = (available.width - box_slot_w - GAP).max(0.0);
258        let s = self.children[0].layout(Size::new(label_avail_w, h));
259        let lx = if self.label_text.is_empty() {
260            box_slot_w
261        } else {
262            box_slot_w + GAP
263        };
264        let ly = (h - s.height) * 0.5;
265        self.children[0].set_bounds(Rect::new(lx, ly, s.width, s.height));
266        let natural_w = if self.label_text.is_empty() {
267            box_slot_w
268        } else {
269            box_slot_w + GAP + s.width
270        };
271        let w = natural_w.min(available.width);
272        self.bounds = Rect::new(0.0, 0.0, w, h);
273        Size::new(w, h)
274    }
275
276    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
277        let v = ctx.visuals();
278        let h = self.bounds.height;
279        let box_x = FOCUS_PAD;
280        let box_y = (h - BOX_SIZE) * 0.5;
281
282        // Focus ring
283        if self.focused {
284            ctx.set_stroke_color(v.accent_focus);
285            ctx.set_line_width(2.0);
286            ctx.begin_path();
287            ctx.rounded_rect(
288                box_x - 1.5,
289                box_y - 1.5,
290                BOX_SIZE + 3.0,
291                BOX_SIZE + 3.0,
292                4.0,
293            );
294            ctx.stroke();
295        }
296
297        let checked = self.effective_checked();
298        let indeterminate = self.is_indeterminate();
299        // The indeterminate state renders with the "on" (accent) fill, matching
300        // our checked look, so it reads as a partial selection rather than empty.
301        let filled = checked || indeterminate;
302
303        // Box background
304        let (unchecked_bg, unchecked_border) = Self::unchecked_colors(&v, self.hovered);
305        let bg = if filled { v.accent } else { unchecked_bg };
306        ctx.set_fill_color(bg);
307        ctx.begin_path();
308        ctx.rounded_rect(box_x, box_y, BOX_SIZE, BOX_SIZE, 3.0);
309        ctx.fill();
310
311        // Box border
312        let border = if filled {
313            v.widget_stroke_active
314        } else {
315            unchecked_border
316        };
317        ctx.set_stroke_color(border);
318        ctx.set_line_width(BOX_STROKE_WIDTH);
319        ctx.begin_path();
320        let stroke_inset = BOX_STROKE_WIDTH * 0.5;
321        ctx.rounded_rect(
322            box_x + stroke_inset,
323            box_y + stroke_inset,
324            BOX_SIZE - BOX_STROKE_WIDTH,
325            BOX_SIZE - BOX_STROKE_WIDTH,
326            3.0,
327        );
328        ctx.stroke();
329
330        // Mark — coordinates in Y-up space (origin = box bottom-left).
331        // Indeterminate takes visual priority: draw a horizontal dash rather
332        // than the checkmark, matching egui's tri-state look.
333        if indeterminate {
334            ctx.set_stroke_color(Color::white());
335            ctx.set_line_width(2.0);
336            ctx.begin_path();
337            ctx.move_to(box_x + 3.0, box_y + BOX_SIZE * 0.5);
338            ctx.line_to(box_x + BOX_SIZE - 3.0, box_y + BOX_SIZE * 0.5);
339            ctx.stroke();
340        } else if checked {
341            ctx.set_stroke_color(Color::white());
342            ctx.set_line_width(2.0);
343            ctx.begin_path();
344            let bx = box_x;
345            let by = box_y;
346            ctx.move_to(bx + 3.0, by + BOX_SIZE * 0.55);
347            ctx.line_to(bx + BOX_SIZE * 0.42, by + BOX_SIZE * 0.28);
348            ctx.line_to(bx + BOX_SIZE - 3.0, by + BOX_SIZE * 0.75);
349            ctx.stroke();
350        }
351
352        // Label colour — child paints itself via the framework's tree walk.
353        let label_color = self.props.label_color.unwrap_or(v.text_color);
354        self.children[0].set_label_color(label_color);
355    }
356
357    fn on_event(&mut self, event: &Event) -> EventResult {
358        match event {
359            Event::MouseMove { pos } => {
360                let was = self.hovered;
361                self.hovered = self.hit_test(*pos);
362                if was != self.hovered {
363                    crate::animation::request_draw();
364                    return EventResult::Consumed;
365                }
366                EventResult::Ignored
367            }
368            Event::MouseDown {
369                button: MouseButton::Left,
370                ..
371            } => EventResult::Consumed,
372            Event::MouseUp {
373                button: MouseButton::Left,
374                pos,
375                ..
376            } => {
377                if self.hit_test(*pos) {
378                    self.toggle();
379                    crate::animation::request_draw();
380                }
381                EventResult::Consumed
382            }
383            Event::KeyDown {
384                key: Key::Char(' '),
385                ..
386            } => {
387                self.toggle();
388                crate::animation::request_draw();
389                EventResult::Consumed
390            }
391            Event::FocusGained => {
392                let was = self.focused;
393                self.focused = true;
394                if !was {
395                    crate::animation::request_draw();
396                    EventResult::Consumed
397                } else {
398                    EventResult::Ignored
399                }
400            }
401            Event::FocusLost => {
402                let was = self.focused;
403                self.focused = false;
404                if was {
405                    crate::animation::request_draw();
406                    EventResult::Consumed
407                } else {
408                    EventResult::Ignored
409                }
410            }
411            _ => EventResult::Ignored,
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    const FONT_BYTES: &[u8] = include_bytes!("../../../demo/assets/CascadiaCode.ttf");
421
422    fn test_font() -> Arc<Font> {
423        Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
424    }
425
426    #[test]
427    fn no_indeterminate_fn_reads_false() {
428        let cb = Checkbox::new("x", test_font(), false);
429        assert!(!cb.is_indeterminate());
430    }
431
432    #[test]
433    fn indeterminate_fn_drives_visual_state() {
434        let flag = Rc::new(Cell::new(true));
435        let f = Rc::clone(&flag);
436        let cb = Checkbox::new("x", test_font(), false).with_indeterminate_fn(move || f.get());
437        assert!(cb.is_indeterminate());
438        flag.set(false);
439        assert!(!cb.is_indeterminate());
440    }
441
442    #[test]
443    fn click_still_toggles_while_indeterminate() {
444        // Indeterminate only affects appearance; toggling must still work.
445        let cb = Checkbox::new("x", test_font(), false).with_indeterminate_fn(|| true);
446        let mut cb = cb;
447        assert!(!cb.effective_checked());
448        cb.toggle();
449        assert!(cb.effective_checked());
450    }
451}