Skip to main content

zest_widget/widget/
checkbox.rs

1//! Toggleable check box with an optional trailing label.
2//!
3//! Immediate-mode: the host owns the `bool` and rebuilds the widget each
4//! frame, passing the current value to [`Checkbox::new`]. A tap emits
5//! `on_toggle(!checked)` so the host can flip its stored value.
6//!
7//! Click semantics mirror [`Button`](crate::Button): the press registers
8//! on `TouchPhase::Down` (visual feedback only), and the toggle message
9//! fires on `TouchPhase::Up` if the press is still active. The runtime
10//! rehydrates the pressed flag each frame via
11//! [`mark_pressed`](Widget::mark_pressed), so drag-off-to-cancel works.
12//!
13//! Colors come from the theme's accent [`Component`](zest_theme::Component):
14//! the filled box uses `accent.base`/`accent.pressed`, the check glyph uses
15//! `accent.on_base`, and the unchecked box border uses `accent.border`.
16
17use super::Widget;
18use alloc::{boxed::Box, string::String};
19use core::marker::PhantomData;
20use embedded_graphics::{
21    pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
22};
23use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
24use zest_theme::Theme;
25
26/// Side length of the box glyph in pixels.
27const BOX_SIZE: u32 = 20;
28/// Gap between the box and the label in pixels.
29const LABEL_GAP: u32 = 6;
30
31/// Toggleable check box. Host owns the `bool`; a tap emits the negated
32/// value through [`on_toggle`](Checkbox::on_toggle).
33pub struct Checkbox<'a, C: PixelColor, M: Clone> {
34    rect: Rectangle,
35    checked: bool,
36    label: Option<String>,
37    id: Option<WidgetId>,
38    on_toggle: Option<Box<dyn Fn(bool) -> M + 'a>>,
39    focused: bool,
40    pressed: bool,
41    width: Length,
42    height: Length,
43    _color: PhantomData<C>,
44}
45
46impl<'a, C: PixelColor, M: Clone> Checkbox<'a, C, M> {
47    /// New check box reflecting `checked`. Position and size are assigned
48    /// by the parent container via `arrange`.
49    pub fn new(checked: bool) -> Self {
50        Self {
51            rect: Rectangle::zero(),
52            checked,
53            label: None,
54            id: None,
55            on_toggle: None,
56            focused: false,
57            pressed: false,
58            width: Length::Shrink,
59            height: Length::Fixed(BOX_SIZE),
60            _color: PhantomData,
61        }
62    }
63
64    /// Trailing label drawn to the right of the box.
65    #[must_use]
66    pub fn label(mut self, label: impl Into<String>) -> Self {
67        self.label = Some(label.into());
68        self
69    }
70
71    /// Callback invoked with the toggled value (`!checked`) on
72    /// each tap. Without it the check box is disabled and ignores touches.
73    #[must_use]
74    pub fn on_toggle<F: Fn(bool) -> M + 'a>(mut self, f: F) -> Self {
75        self.on_toggle = Some(Box::new(f));
76        self
77    }
78
79    /// Set a stable id so this checkbox can participate in focus traversal.
80    #[must_use]
81    pub fn id(mut self, id: WidgetId) -> Self {
82        self.id = Some(id);
83        self
84    }
85
86    /// Width sizing intent.
87    #[must_use]
88    pub fn width(mut self, width: impl Into<Length>) -> Self {
89        self.width = width.into();
90        self
91    }
92
93    /// Height sizing intent.
94    #[must_use]
95    pub fn height(mut self, height: impl Into<Length>) -> Self {
96        self.height = height.into();
97        self
98    }
99
100    /// True iff a toggle callback is bound. Disabled check boxes render
101    /// dimmed and ignore touches.
102    pub fn is_enabled(&self) -> bool {
103        self.on_toggle.is_some()
104    }
105
106    fn intrinsic(&self) -> Size {
107        // Approximate label width with a fixed glyph advance — no theme
108        // (font) reference is available at measure time.
109        let label_w = self
110            .label
111            .as_ref()
112            .map_or(0, |l| LABEL_GAP + l.chars().count() as u32 * 8);
113        Size::new(BOX_SIZE + label_w, BOX_SIZE)
114    }
115
116    fn box_rect(&self) -> Rectangle {
117        let y = self.rect.top_left.y + (self.rect.size.height.saturating_sub(BOX_SIZE) / 2) as i32;
118        Rectangle::new(
119            Point::new(self.rect.top_left.x, y),
120            Size::new(BOX_SIZE, BOX_SIZE),
121        )
122    }
123
124    fn hit_test(&self, point: Point) -> bool {
125        let tl = self.rect.top_left;
126        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
127        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
128    }
129}
130
131impl<'a, C: PixelColor, M: Clone> Widget<C, M> for Checkbox<'a, C, M> {
132    fn measure(&mut self, constraints: Constraints) -> Size {
133        let intrinsic = self.intrinsic();
134        let w = self.width.resolve(intrinsic.width, constraints.max.width);
135        let h = self
136            .height
137            .resolve(intrinsic.height, constraints.max.height);
138        constraints.clamp(Size::new(w, h))
139    }
140
141    fn preferred_size(&self) -> (Length, Length) {
142        (self.width, self.height)
143    }
144
145    fn arrange(&mut self, rect: Rectangle) {
146        self.rect = rect;
147    }
148
149    fn rect(&self) -> Rectangle {
150        self.rect
151    }
152
153    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
154        if !self.is_enabled() || !self.hit_test(point) {
155            if matches!(phase, TouchPhase::Up | TouchPhase::Moved) {
156                self.pressed = false;
157            }
158            return None;
159        }
160        match phase {
161            TouchPhase::Down => {
162                self.pressed = true;
163                None
164            }
165            TouchPhase::Up => {
166                if self.pressed {
167                    self.pressed = false;
168                    self.on_toggle.as_ref().map(|cb| cb(!self.checked))
169                } else {
170                    None
171                }
172            }
173            TouchPhase::Moved => None,
174        }
175    }
176
177    fn mark_pressed(&mut self, point: Point) {
178        if self.is_enabled() && self.hit_test(point) {
179            self.pressed = true;
180        }
181    }
182
183    fn widget_id(&self) -> Option<WidgetId> {
184        self.id
185    }
186
187    fn is_focusable(&self) -> bool {
188        self.id.is_some() && self.is_enabled()
189    }
190
191    fn handle_action(&mut self, action: UiAction) -> Option<M> {
192        if !self.is_enabled() {
193            return None;
194        }
195
196        match action {
197            UiAction::Activate => self.on_toggle.as_ref().map(|cb| cb(!self.checked)),
198            _ => None,
199        }
200    }
201
202    fn sync_focus(&mut self, focused: Option<WidgetId>) {
203        self.focused = self.id.is_some() && self.id == focused;
204    }
205
206    fn focus_at(&self, point: Point) -> Option<WidgetId> {
207        if self.is_focusable() && self.hit_test(point) {
208            self.id
209        } else {
210            None
211        }
212    }
213
214    fn draw<'t>(
215        &self,
216        renderer: &mut dyn Renderer<C>,
217        theme: &Theme<'t, C>,
218    ) -> Result<(), RenderError> {
219        let accent = &theme.accent;
220        let box_rect = self.box_rect();
221
222        if self.checked {
223            let fill = if self.pressed {
224                accent.pressed
225            } else {
226                accent.base
227            };
228            let border = if self.focused {
229                accent.base
230            } else {
231                accent.border
232            };
233            renderer.fill_rect(box_rect, fill)?;
234            renderer.stroke_rect(box_rect, border)?;
235            // Draw a check mark as two strokes forming a tick.
236            let x = box_rect.top_left.x;
237            let y = box_rect.top_left.y;
238            let s = BOX_SIZE as i32;
239            renderer.stroke_line(
240                Point::new(x + s * 3 / 16, y + s / 2),
241                Point::new(x + s * 7 / 16, y + s * 11 / 16),
242                accent.on_base,
243                2,
244            )?;
245            renderer.stroke_line(
246                Point::new(x + s * 7 / 16, y + s * 11 / 16),
247                Point::new(x + s * 13 / 16, y + s * 5 / 16),
248                accent.on_base,
249                2,
250            )?;
251        } else {
252            let bg = if self.pressed {
253                accent.pressed
254            } else {
255                theme.background.base
256            };
257            let border = if self.focused {
258                accent.base
259            } else {
260                accent.border
261            };
262            renderer.fill_rect(box_rect, bg)?;
263            renderer.stroke_rect(box_rect, border)?;
264        }
265
266        if let Some(label) = &self.label {
267            let font = theme.default_font();
268            let text_x = box_rect.top_left.x + BOX_SIZE as i32 + LABEL_GAP as i32;
269            let center_y = self.rect.top_left.y
270                + self.rect.size.height as i32 / 2
271                + font.character_size.height as i32 / 3;
272            let color = if !self.is_enabled() {
273                theme.palette.neutral_2
274            } else if self.focused {
275                theme.accent.base
276            } else {
277                theme.background.on_base
278            };
279            renderer.draw_text(
280                label,
281                Point::new(text_x, center_y),
282                font,
283                color,
284                Alignment::Left,
285            )?;
286        }
287
288        Ok(())
289    }
290}