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