Skip to main content

denise_ui/widgets/
checkbox.rs

1//! A box, a tick, and a boolean.
2
3use alloc::string::String;
4
5use denise::Pen;
6use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role, Theme};
7use denise_text::{TextEngine, TextStyle};
8
9use crate::widget::{
10    Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
11};
12use crate::widgets::describe::{
13    Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
14};
15use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair};
16
17/// A checkbox with an optional label beside it.
18///
19/// The message is a function of the **new** value rather than a fixed value, so
20/// an application matches on what the checkbox became rather than looking the
21/// widget up afterwards. An enum's tuple variant already is such a function:
22///
23/// ```
24/// # use denise_ui::Checkbox;
25/// enum Message { Muted(bool) }
26/// Checkbox::new("Mute", Message::Muted);
27/// ```
28///
29/// A plain `fn` pointer rather than a closure, so this needs no allocation, no
30/// `M: Clone`, and works in `no_std`.
31///
32/// # Why there is no indeterminate state
33///
34/// The third state only means anything for a checkbox that summarises other
35/// checkboxes — a parent over a list of children — and neither the list nor the
36/// hierarchy exists here yet. It is also additive when it does: HTML keeps
37/// `indeterminate` as a property separate from `checked` precisely because it is
38/// a way of *drawing* a checkbox rather than a third value it can hold. So this
39/// stays a `bool` and gains a flag later, rather than becoming an enum everybody
40/// has to match on for a case nothing can currently produce.
41#[derive(Clone, Debug)]
42pub struct Checkbox<M> {
43    label: String,
44    checked: bool,
45    message: Option<fn(bool) -> M>,
46    role: Role,
47    style: TextStyle,
48}
49
50impl<M> Checkbox<M> {
51    /// An unchecked box whose message is built from the value it changes to.
52    pub fn new(label: impl Into<String>, message: fn(bool) -> M) -> Self {
53        Self {
54            label: label.into(),
55            checked: false,
56            message: Some(message),
57            role: Role::Primary,
58            style: TextStyle::built_in(16),
59        }
60    }
61
62    /// A checkbox that emits nothing, for a value the application reads rather
63    /// than reacts to.
64    pub fn inert(label: impl Into<String>) -> Self {
65        Self {
66            label: label.into(),
67            checked: false,
68            message: None,
69            role: Role::Primary,
70            style: TextStyle::built_in(16),
71        }
72    }
73
74    /// Sets the initial value.
75    pub fn with_checked(mut self, checked: bool) -> Self {
76        self.checked = checked;
77        self
78    }
79
80    /// Sets the colour role of the filled box. The tick comes from the theme's
81    /// pairing, so it stays readable whichever role and theme are chosen.
82    pub fn with_role(mut self, role: Role) -> Self {
83        self.role = role;
84        self
85    }
86
87    /// Sets the label's font and size.
88    pub fn with_style(mut self, style: TextStyle) -> Self {
89        self.style = style;
90        self
91    }
92
93    /// Sets the label's size, keeping the font.
94    pub fn with_size(mut self, size_px: u16) -> Self {
95        self.style.size_px = size_px;
96        self
97    }
98
99    /// Whether the box is ticked.
100    #[inline]
101    pub const fn checked(&self) -> bool {
102        self.checked
103    }
104
105    /// Sets the value **without emitting anything**.
106    ///
107    /// The message reports what a person did. An application that assigns here
108    /// and then receives its own message back would either loop or have to guard
109    /// against itself, which is the bug this rule exists to prevent.
110    pub fn set_checked(&mut self, checked: bool) {
111        self.checked = checked;
112    }
113
114    /// The current label.
115    #[inline]
116    pub fn label(&self) -> &str {
117        &self.label
118    }
119
120    /// Replaces the label.
121    pub fn set_label(&mut self, label: impl Into<String>) {
122        self.label = label.into();
123    }
124
125    /// Replaces the colour role.
126    pub fn set_role(&mut self, role: Role) {
127        self.role = role;
128    }
129
130    /// Replaces the label's font and size.
131    ///
132    /// For an application that registers a font after building its tree, which is
133    /// the ordinary case: the tree has to exist before anyone knows whether the
134    /// font file was there.
135    pub fn set_style(&mut self, style: TextStyle) {
136        self.style = style;
137    }
138
139    /// Width this checkbox needs for its box, its gap and its label.
140    ///
141    /// Takes the theme because the box is a theme metric — a touch theme's box is
142    /// 28 logical pixels where a mouse theme's is 20 — and the engine because
143    /// with a proportional font the label's width is not the character count
144    /// times anything.
145    pub fn preferred_width(&self, theme: &Theme, engine: &mut TextEngine) -> i32 {
146        let side = theme.metrics.size_selector;
147        let text = engine.measure_line(self.style, &self.label);
148        if self.label.is_empty() {
149            side
150        } else {
151            side + gap(side) + text
152        }
153    }
154}
155
156/// Space between the box and its label.
157#[inline]
158const fn gap(side: i32) -> i32 {
159    // `Ord::max` is not const yet, and this is const so `preferred_width` and the
160    // paint path cannot drift apart.
161    if side < 2 { 1 } else { side / 2 }
162}
163
164/// The box itself: a square at the leading edge, centred vertically.
165///
166/// Clamped to the height it is given, so a checkbox in a row shorter than the
167/// theme's selector size draws a smaller box rather than one that overflows into
168/// its neighbours.
169fn box_rect(bounds: Rect, theme: &Theme) -> Rect {
170    let side = theme
171        .metrics
172        .size_selector
173        .min(bounds.height)
174        .min(bounds.width)
175        .max(1);
176    Rect::new(bounds.x, bounds.y + (bounds.height - side) / 2, side, side)
177}
178
179/// How heavy the tick's stroke is, for a box `side` pixels across.
180///
181/// Derived from the box rather than from `Metrics::border`: the border metric is
182/// about the line around a control, and tying a checkmark's weight to it means a
183/// theme that wants hairline borders gets an illegible tick.
184#[inline]
185const fn tick_weight(side: i32) -> i32 {
186    if side / 8 < 2 { 2 } else { side / 8 }
187}
188
189/// Draws the tick inside `area`.
190///
191/// Two segments, at the proportions a checkmark is normally drawn at. Thickness
192/// is faked by drawing the pair several times offset downwards, because
193/// [`Canvas::draw_line`] is deliberately one pixel wide and there is no
194/// thick-line primitive — for a stroke at roughly 45° a vertical offset gives an
195/// effective width of about `t / √2`, which is close enough that nobody counting
196/// pixels on a 20-pixel box would notice, and far better than the anaemic hairline
197/// a single pass gives.
198fn draw_tick(canvas: &mut Pen<'_>, area: Rect, color: denise::Color, thickness: i32) {
199    let s = area.width;
200    // Proportions of the box, in eighths and sixteenths so the arithmetic stays
201    // integral at every size a selector metric can produce.
202    let start = Point::new(area.x + s * 7 / 32, area.y + s * 17 / 32);
203    let elbow = Point::new(area.x + s * 13 / 32, area.y + s * 23 / 32);
204    let end = Point::new(area.x + s * 25 / 32, area.y + s * 9 / 32);
205
206    for step in 0..thickness.max(1) {
207        let dy = step;
208        canvas.draw_line(
209            Point::new(start.x, start.y + dy),
210            Point::new(elbow.x, elbow.y + dy),
211            color,
212        );
213        canvas.draw_line(
214            Point::new(elbow.x, elbow.y + dy),
215            Point::new(end.x, end.y + dy),
216            color,
217        );
218    }
219}
220
221impl<M: 'static> Widget<M> for Checkbox<M> {
222    fn describe(&self) -> Option<&dyn DynDescribe> {
223        Some(self)
224    }
225
226    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
227        Some(self)
228    }
229    fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
230        Measured::both(
231            self.preferred_width(ctx.theme, ctx.text),
232            ctx.theme.metrics.size_selector.max(1),
233        )
234    }
235
236    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
237        let area = box_rect(ctx.bounds, ctx.theme);
238        // Never more than a half-side, or the "rounded rect" is a circle with the
239        // corners guessed at — which is what a radio button should look like and a
240        // checkbox should not.
241        let radius = ctx.theme.radius(Radius::Selector).min(area.width / 2);
242
243        // The label always uses the base pairing, so it stays plain text on the
244        // panel rather than taking the box's role colour. Reading it through
245        // `interactive_pair` is what makes it mute itself when disabled.
246        let (surface, on_surface) = interactive_pair(ctx.theme, Role::Base100, ctx.state);
247
248        if self.checked {
249            let (fill, mark) = interactive_pair(ctx.theme, self.role, ctx.state);
250            canvas.fill_rounded_rect(area, radius, fill);
251            draw_tick(canvas, area, mark, tick_weight(area.width));
252        } else {
253            canvas.fill_rounded_rect(area, radius, surface);
254            canvas.stroke_rounded_rect(
255                area,
256                radius,
257                ctx.theme.metrics.border,
258                ctx.theme.color(Role::Base300),
259            );
260        }
261
262        if ctx.state.contains(VisualState::FOCUSED) {
263            // Around the whole widget, label included, because the label is part
264            // of the hit area and a ring around only the box would say otherwise.
265            focus_ring(
266                ctx.theme,
267                ctx.bounds,
268                ctx.theme.radius(Radius::Field),
269                canvas,
270            );
271        }
272
273        if self.label.is_empty() {
274            return;
275        }
276        let text = Rect::from_edges(
277            area.right() + gap(area.width),
278            ctx.bounds.y,
279            ctx.bounds.right(),
280            ctx.bounds.bottom(),
281        );
282        if !text.is_empty() {
283            draw_aligned(
284                canvas,
285                ctx.text,
286                self.style,
287                text,
288                (Align::Start, Align::Center),
289                &self.label,
290                on_surface,
291            );
292        }
293    }
294
295    fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
296        let toggled = match event {
297            Event::Input(InputEvent::PointerButton {
298                state: ElementState::Up,
299                position,
300                ..
301            }) => ctx.bounds.contains(*position),
302            Event::Input(InputEvent::TouchUp {
303                position,
304                cancelled: false,
305                ..
306            }) => ctx.bounds.contains(*position),
307            // Space and not Enter. Enter belongs to the form's default action, and
308            // a checkbox that swallows it is why a dialog stops submitting when
309            // focus happens to be sitting on one.
310            Event::Input(InputEvent::Key {
311                code: KeyCode::Space,
312                state: ElementState::Down,
313                repeat: false,
314                ..
315            }) => ctx.state.contains(VisualState::FOCUSED),
316            _ => return Handled::No,
317        };
318        if !toggled {
319            return Handled::No;
320        }
321        self.checked = !self.checked;
322        if let Some(message) = self.message {
323            ctx.emit(message(self.checked));
324        }
325        Handled::Yes
326    }
327
328    fn accepts_pointer(&self) -> bool {
329        true
330    }
331
332    fn focusable(&self) -> bool {
333        true
334    }
335}
336
337impl<M> Describe for Checkbox<M> {
338    const KIND: &'static str = "checkbox";
339    const DOC: &'static str = "A box and a tick: one thing that is either on or off.";
340    const GROUP: Group = Group::Input;
341    const ICON: &'static denise::icon::Icon = &super::icons::CHECKBOX;
342
343    const PROPERTIES: &'static [Property] = &[
344        Property::new("text", PropertyKind::Text, "The label beside the box."),
345        Property::new("checked", PropertyKind::Bool, "Whether the box is ticked."),
346        Property::new(
347            "on-change",
348            PropertyKind::Message(Payload::Bool),
349            "The message built from the value the box changes to. Omitted, the checkbox is inert.",
350        ),
351        Property::new(
352            "role",
353            PropertyKind::Enum(ROLES),
354            "Colour role of the filled box. The tick comes from the theme's pairing, so it stays readable whichever role is chosen.",
355        ),
356        Property::new(
357            "size",
358            PropertyKind::Int { min: 6, max: 96 },
359            "Label text size in logical pixels. The box itself is a theme metric.",
360        )
361        .in_pixels(),
362    ];
363
364    fn get(&self, name: &str) -> Option<Value> {
365        Some(match name {
366            "text" => Value::text(self.label.as_str()),
367            "checked" => Value::Bool(self.checked),
368            // A `fn(bool) -> M` cannot be reported as a `Value`. See the
369            // `describe` module docs.
370            "on-change" => return None,
371            "role" => Value::role(self.role),
372            "size" => Value::Int(i32::from(self.style.size_px)),
373            _ => return None,
374        })
375    }
376
377    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
378        match name {
379            "text" => self.label = value.as_text()?,
380            // Through the setter, which is the one that does not emit: a value
381            // a designer typed is not a person ticking the box.
382            "checked" => self.set_checked(value.as_bool()?),
383            "on-change" => return Err(Mismatch::Supplied),
384            "role" => self.role = value.as_role()?,
385            "size" => self.style.size_px = value.as_size()?,
386            _ => return Err(Mismatch::Unknown),
387        }
388        Ok(())
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use denise::theme;
396
397    /// The box tracks the theme's selector metric, which a touch theme makes
398    /// larger — the whole reason that metric exists.
399    #[test]
400    fn the_box_follows_the_theme_and_sits_at_the_leading_edge() {
401        let bounds = Rect::new(10, 20, 200, 40);
402
403        let mouse = box_rect(bounds, &theme::DARK);
404        assert_eq!(mouse.width, theme::DARK.metrics.size_selector);
405        assert_eq!(mouse.x, bounds.x, "the box is at the leading edge");
406        assert_eq!(
407            mouse.y + mouse.height / 2,
408            bounds.y + bounds.height / 2,
409            "and centred against the label beside it"
410        );
411
412        let touch = theme::DARK.with_metrics(denise::theme::Metrics::TOUCH);
413        assert!(box_rect(bounds, &touch).width > mouse.width);
414    }
415
416    /// A checkbox in a row shorter than the metric draws a smaller box rather
417    /// than one that overflows into whatever is above and below it.
418    #[test]
419    fn a_short_row_shrinks_the_box_instead_of_overflowing() {
420        let bounds = Rect::new(0, 0, 200, 12);
421        let area = box_rect(bounds, &theme::DARK);
422        assert!(area.width <= 12);
423        assert!(area.height <= bounds.height);
424        assert!(area.width >= 1, "and never collapses to nothing");
425    }
426
427    /// Degenerate bounds must still produce a drawable square. A zero-width box
428    /// reaches `fill_rounded_rect` with a radius of zero and a canvas that has to
429    /// cope; a *negative* one would be a rectangle with inverted edges.
430    #[test]
431    fn degenerate_bounds_still_give_a_square_with_area() {
432        for bounds in [
433            Rect::new(0, 0, 0, 0),
434            Rect::new(0, 0, 1, 40),
435            Rect::new(0, 0, 40, 1),
436        ] {
437            let area = box_rect(bounds, &theme::DARK);
438            assert!(area.width >= 1 && area.height >= 1, "{bounds:?}");
439            assert_eq!(area.width, area.height, "{bounds:?} is not square");
440        }
441    }
442
443    /// The label is measured, not guessed, and a checkbox with no label is just
444    /// the box — no trailing gap for text that is not there.
445    #[test]
446    fn the_preferred_width_covers_the_box_the_gap_and_the_label() {
447        let mut engine = TextEngine::new();
448        let style = TextStyle::built_in(16);
449        let side = theme::DARK.metrics.size_selector;
450
451        let labelled: Checkbox<()> = Checkbox::inert("Enable logging");
452        let text = engine.measure_line(style, "Enable logging");
453        assert_eq!(
454            labelled.preferred_width(&theme::DARK, &mut engine),
455            side + gap(side) + text
456        );
457
458        let bare: Checkbox<()> = Checkbox::inert("");
459        assert_eq!(bare.preferred_width(&theme::DARK, &mut engine), side);
460    }
461
462    /// Assigning is not the same as somebody clicking. A `set_checked` that
463    /// emitted would come straight back to an application that had just handled
464    /// the message it was reacting to.
465    #[test]
466    fn setting_the_value_programmatically_is_silent() {
467        let mut checkbox: Checkbox<bool> = Checkbox::new("Mute", |on| on);
468        assert!(!checkbox.checked());
469        checkbox.set_checked(true);
470        assert!(checkbox.checked());
471        // Nothing to assert about messages here: `set_checked` has no way to emit
472        // one. That is the point, and this test exists so that giving it one
473        // would have to be a deliberate change to a signature.
474    }
475}