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].set_bounds(Rect::new(lx, ly, s.width, s.height));
243        let natural_w = if self.label_text.is_empty() {
244            box_slot_w
245        } else {
246            box_slot_w + GAP + s.width
247        };
248        let w = natural_w.min(available.width);
249        self.bounds = Rect::new(0.0, 0.0, w, h);
250        Size::new(w, h)
251    }
252
253    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
254        let v = ctx.visuals();
255        let h = self.bounds.height;
256        let box_x = FOCUS_PAD;
257        let box_y = (h - BOX_SIZE) * 0.5;
258
259        // Focus ring
260        if self.focused {
261            ctx.set_stroke_color(v.accent_focus);
262            ctx.set_line_width(2.0);
263            ctx.begin_path();
264            ctx.rounded_rect(
265                box_x - 1.5,
266                box_y - 1.5,
267                BOX_SIZE + 3.0,
268                BOX_SIZE + 3.0,
269                4.0,
270            );
271            ctx.stroke();
272        }
273
274        let checked = self.effective_checked();
275
276        // Box background
277        let (unchecked_bg, unchecked_border) = Self::unchecked_colors(&v, self.hovered);
278        let bg = if checked { v.accent } else { unchecked_bg };
279        ctx.set_fill_color(bg);
280        ctx.begin_path();
281        ctx.rounded_rect(box_x, box_y, BOX_SIZE, BOX_SIZE, 3.0);
282        ctx.fill();
283
284        // Box border
285        let border = if checked {
286            v.widget_stroke_active
287        } else {
288            unchecked_border
289        };
290        ctx.set_stroke_color(border);
291        ctx.set_line_width(BOX_STROKE_WIDTH);
292        ctx.begin_path();
293        let stroke_inset = BOX_STROKE_WIDTH * 0.5;
294        ctx.rounded_rect(
295            box_x + stroke_inset,
296            box_y + stroke_inset,
297            BOX_SIZE - BOX_STROKE_WIDTH,
298            BOX_SIZE - BOX_STROKE_WIDTH,
299            3.0,
300        );
301        ctx.stroke();
302
303        // Checkmark — coordinates in Y-up space (origin = box bottom-left).
304        if checked {
305            ctx.set_stroke_color(Color::white());
306            ctx.set_line_width(2.0);
307            ctx.begin_path();
308            let bx = box_x;
309            let by = box_y;
310            ctx.move_to(bx + 3.0, by + BOX_SIZE * 0.55);
311            ctx.line_to(bx + BOX_SIZE * 0.42, by + BOX_SIZE * 0.28);
312            ctx.line_to(bx + BOX_SIZE - 3.0, by + BOX_SIZE * 0.75);
313            ctx.stroke();
314        }
315
316        // Label colour — child paints itself via the framework's tree walk.
317        let label_color = self.props.label_color.unwrap_or(v.text_color);
318        self.children[0].set_label_color(label_color);
319    }
320
321    fn on_event(&mut self, event: &Event) -> EventResult {
322        match event {
323            Event::MouseMove { pos } => {
324                let was = self.hovered;
325                self.hovered = self.hit_test(*pos);
326                if was != self.hovered {
327                    crate::animation::request_draw();
328                    return EventResult::Consumed;
329                }
330                EventResult::Ignored
331            }
332            Event::MouseDown {
333                button: MouseButton::Left,
334                ..
335            } => EventResult::Consumed,
336            Event::MouseUp {
337                button: MouseButton::Left,
338                pos,
339                ..
340            } => {
341                if self.hit_test(*pos) {
342                    self.toggle();
343                    crate::animation::request_draw();
344                }
345                EventResult::Consumed
346            }
347            Event::KeyDown {
348                key: Key::Char(' '),
349                ..
350            } => {
351                self.toggle();
352                crate::animation::request_draw();
353                EventResult::Consumed
354            }
355            Event::FocusGained => {
356                let was = self.focused;
357                self.focused = true;
358                if !was {
359                    crate::animation::request_draw();
360                    EventResult::Consumed
361                } else {
362                    EventResult::Ignored
363                }
364            }
365            Event::FocusLost => {
366                let was = self.focused;
367                self.focused = false;
368                if was {
369                    crate::animation::request_draw();
370                    EventResult::Consumed
371                } else {
372                    EventResult::Ignored
373                }
374            }
375            _ => EventResult::Ignored,
376        }
377    }
378}