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    /// Tracked label text — used for empty-check during layout and to rebuild
60    /// the Label child when font size changes.
61    label_text: String,
62}
63
64impl Checkbox {
65    pub fn new(label: impl Into<String>, font: Arc<Font>, checked: bool) -> Self {
66        let label_text: String = label.into();
67        let font_size = 14.0;
68        let label_widget = Label::new(&label_text, Arc::clone(&font)).with_font_size(font_size);
69        Self {
70            bounds: Rect::default(),
71            children: vec![Box::new(label_widget)],
72            base: WidgetBase::new(),
73            font,
74            props: CheckboxProps {
75                checked,
76                font_size,
77                label_color: None,
78            },
79            state_cell: None,
80            hovered: false,
81            focused: false,
82            on_change: None,
83            label_text,
84        }
85    }
86
87    pub fn with_font_size(mut self, size: f64) -> Self {
88        self.props.font_size = size;
89        self.children[0] =
90            Box::new(Label::new(&self.label_text, Arc::clone(&self.font)).with_font_size(size));
91        self
92    }
93    pub fn with_label_color(mut self, c: Color) -> Self {
94        self.props.label_color = Some(c);
95        self
96    }
97
98    /// Bind checked state to a shared cell.
99    ///
100    /// When set, `paint` reads from the cell (so external changes — e.g. a
101    /// window's close button — are reflected immediately), and `toggle` writes
102    /// to it so both directions stay in sync.
103    pub fn with_state_cell(mut self, cell: Rc<Cell<bool>>) -> Self {
104        self.state_cell = Some(cell);
105        self
106    }
107
108    pub fn with_margin(mut self, m: Insets) -> Self {
109        self.base.margin = m;
110        self
111    }
112    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
113        self.base.h_anchor = h;
114        self
115    }
116    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
117        self.base.v_anchor = v;
118        self
119    }
120    pub fn with_min_size(mut self, s: Size) -> Self {
121        self.base.min_size = s;
122        self
123    }
124    pub fn with_max_size(mut self, s: Size) -> Self {
125        self.base.max_size = s;
126        self
127    }
128
129    pub fn on_change(mut self, cb: impl FnMut(bool) + 'static) -> Self {
130        self.on_change = Some(Box::new(cb));
131        self
132    }
133
134    pub fn checked(&self) -> bool {
135        self.props.checked
136    }
137    pub fn set_checked(&mut self, v: bool) {
138        self.props.checked = v;
139    }
140
141    fn toggle(&mut self) {
142        let new_val = !self.effective_checked();
143        self.props.checked = new_val;
144        if let Some(ref cell) = self.state_cell {
145            cell.set(new_val);
146        }
147        if let Some(cb) = self.on_change.as_mut() {
148            cb(new_val);
149        }
150    }
151
152    /// Returns the authoritative checked state: the cell value if bound, else
153    /// the internal `checked` field.
154    #[inline]
155    fn effective_checked(&self) -> bool {
156        if let Some(ref cell) = self.state_cell {
157            cell.get()
158        } else {
159            self.props.checked
160        }
161    }
162
163    fn unchecked_colors(v: &crate::theme::Visuals, hovered: bool) -> (Color, Color) {
164        let luma = v.bg_color.r * 0.299 + v.bg_color.g * 0.587 + v.bg_color.b * 0.114;
165        if luma < 0.5 {
166            let fill = if hovered { v.widget_bg } else { v.window_fill };
167            (fill, Color::rgba(1.0, 1.0, 1.0, 0.34))
168        } else {
169            let fill = if hovered {
170                v.widget_bg_hovered
171            } else {
172                v.widget_bg
173            };
174            (fill, v.widget_stroke)
175        }
176    }
177}
178
179impl Widget for Checkbox {
180    fn type_name(&self) -> &'static str {
181        "Checkbox"
182    }
183    fn bounds(&self) -> Rect {
184        self.bounds
185    }
186    fn set_bounds(&mut self, b: Rect) {
187        self.bounds = b;
188    }
189    fn children(&self) -> &[Box<dyn Widget>] {
190        &self.children
191    }
192    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
193        &mut self.children
194    }
195
196    #[cfg(feature = "reflect")]
197    fn as_reflect(&self) -> Option<&dyn bevy_reflect::Reflect> {
198        Some(&self.props)
199    }
200    #[cfg(feature = "reflect")]
201    fn as_reflect_mut(&mut self) -> Option<&mut dyn bevy_reflect::Reflect> {
202        Some(&mut self.props)
203    }
204
205    fn is_focusable(&self) -> bool {
206        true
207    }
208
209    fn margin(&self) -> Insets {
210        self.base.margin
211    }
212    fn widget_base(&self) -> Option<&WidgetBase> {
213        Some(&self.base)
214    }
215    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
216        Some(&mut self.base)
217    }
218    fn h_anchor(&self) -> HAnchor {
219        self.base.h_anchor
220    }
221    fn v_anchor(&self) -> VAnchor {
222        self.base.v_anchor
223    }
224    fn min_size(&self) -> Size {
225        self.base.min_size
226    }
227    fn max_size(&self) -> Size {
228        self.base.max_size
229    }
230
231    fn layout(&mut self, available: Size) -> Size {
232        let box_slot_w = BOX_SIZE + FOCUS_PAD * 2.0;
233        let h = (BOX_SIZE + FOCUS_PAD * 2.0).max(self.props.font_size * 1.25);
234        let label_avail_w = (available.width - box_slot_w - GAP).max(0.0);
235        let s = self.children[0].layout(Size::new(label_avail_w, h));
236        let lx = if self.label_text.is_empty() {
237            box_slot_w
238        } else {
239            box_slot_w + GAP
240        };
241        let ly = (h - s.height) * 0.5;
242        self.children[0]
243            .set_bounds(Rect::new(lx, ly, s.width, s.height));
244        let natural_w = if self.label_text.is_empty() {
245            box_slot_w
246        } else {
247            box_slot_w + GAP + s.width
248        };
249        let w = natural_w.min(available.width);
250        self.bounds = Rect::new(0.0, 0.0, w, h);
251        Size::new(w, h)
252    }
253
254    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
255        let v = ctx.visuals();
256        let h = self.bounds.height;
257        let box_x = FOCUS_PAD;
258        let box_y = (h - BOX_SIZE) * 0.5;
259
260        // Focus ring
261        if self.focused {
262            ctx.set_stroke_color(v.accent_focus);
263            ctx.set_line_width(2.0);
264            ctx.begin_path();
265            ctx.rounded_rect(
266                box_x - 1.5,
267                box_y - 1.5,
268                BOX_SIZE + 3.0,
269                BOX_SIZE + 3.0,
270                4.0,
271            );
272            ctx.stroke();
273        }
274
275        let checked = self.effective_checked();
276
277        // Box background
278        let (unchecked_bg, unchecked_border) = Self::unchecked_colors(&v, self.hovered);
279        let bg = if checked { v.accent } else { unchecked_bg };
280        ctx.set_fill_color(bg);
281        ctx.begin_path();
282        ctx.rounded_rect(box_x, box_y, BOX_SIZE, BOX_SIZE, 3.0);
283        ctx.fill();
284
285        // Box border
286        let border = if checked {
287            v.widget_stroke_active
288        } else {
289            unchecked_border
290        };
291        ctx.set_stroke_color(border);
292        ctx.set_line_width(BOX_STROKE_WIDTH);
293        ctx.begin_path();
294        let stroke_inset = BOX_STROKE_WIDTH * 0.5;
295        ctx.rounded_rect(
296            box_x + stroke_inset,
297            box_y + stroke_inset,
298            BOX_SIZE - BOX_STROKE_WIDTH,
299            BOX_SIZE - BOX_STROKE_WIDTH,
300            3.0,
301        );
302        ctx.stroke();
303
304        // Checkmark — coordinates in Y-up space (origin = box bottom-left).
305        if checked {
306            ctx.set_stroke_color(Color::white());
307            ctx.set_line_width(2.0);
308            ctx.begin_path();
309            let bx = box_x;
310            let by = box_y;
311            ctx.move_to(bx + 3.0, by + BOX_SIZE * 0.55);
312            ctx.line_to(bx + BOX_SIZE * 0.42, by + BOX_SIZE * 0.28);
313            ctx.line_to(bx + BOX_SIZE - 3.0, by + BOX_SIZE * 0.75);
314            ctx.stroke();
315        }
316
317        // Label colour — child paints itself via the framework's tree walk.
318        let label_color = self.props.label_color.unwrap_or(v.text_color);
319        self.children[0].set_label_color(label_color);
320    }
321
322    fn on_event(&mut self, event: &Event) -> EventResult {
323        match event {
324            Event::MouseMove { pos } => {
325                let was = self.hovered;
326                self.hovered = self.hit_test(*pos);
327                if was != self.hovered {
328                    crate::animation::request_draw();
329                    return EventResult::Consumed;
330                }
331                EventResult::Ignored
332            }
333            Event::MouseDown {
334                button: MouseButton::Left,
335                ..
336            } => EventResult::Consumed,
337            Event::MouseUp {
338                button: MouseButton::Left,
339                pos,
340                ..
341            } => {
342                if self.hit_test(*pos) {
343                    self.toggle();
344                    crate::animation::request_draw();
345                }
346                EventResult::Consumed
347            }
348            Event::KeyDown {
349                key: Key::Char(' '),
350                ..
351            } => {
352                self.toggle();
353                crate::animation::request_draw();
354                EventResult::Consumed
355            }
356            Event::FocusGained => {
357                let was = self.focused;
358                self.focused = true;
359                if !was {
360                    crate::animation::request_draw();
361                    EventResult::Consumed
362                } else {
363                    EventResult::Ignored
364                }
365            }
366            Event::FocusLost => {
367                let was = self.focused;
368                self.focused = false;
369                if was {
370                    crate::animation::request_draw();
371                    EventResult::Consumed
372                } else {
373                    EventResult::Ignored
374                }
375            }
376            _ => EventResult::Ignored,
377        }
378    }
379}