Skip to main content

qframe/widgets/
checkbox.rs

1//! Checkboxes, and the two-cell choice box they share with radio groups.
2
3use std::time::Duration;
4
5use super::ToggleMessage;
6use super::press::{self, Press};
7use crate::event::Event;
8use crate::geometry::{Rect, Size};
9use crate::motion::{Easing, Tween};
10use crate::style::CellStyle;
11use crate::text;
12use crate::theme::State;
13use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
14
15/// Width of the choice box, in cells.
16pub(super) const BOX: u16 = 2;
17
18/// Width of the check style's box, in cells.
19const CHECK_BOX: u16 = 3;
20
21/// Cells between a mark and its label.
22pub(super) const LABEL_GAP: u16 = 2;
23
24/// How many `motion.step`s a choice box takes to blend between empty and filled: as long as a
25/// switch knob takes to cross its track, so the toggles of a form change together.
26const BLEND_STEPS: u32 = 3;
27
28/// Time between frames while a box blends.
29const BLEND_FRAME: Duration = Duration::from_millis(16);
30
31/// How a [`Checkbox`] looks.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum CheckboxStyle {
34    /// A two-cell box of solid colour: filled when checked, the empty tone when not, and filled
35    /// in its left cell only when partly checked. No glyph, so it looks the same in every glyph
36    /// mode. It looks exactly like a radio group's box.
37    #[default]
38    Box,
39    /// A three-cell box with a check `✓` when checked and a dash when partly checked.
40    Check,
41}
42
43/// A box that is checked or not, with an optional label.
44///
45/// The default box is two cells of solid colour that blend from the empty tone to the filled
46/// tone over the theme's `motion.step` times three when it changes; with reduced motion the
47/// change is immediate. A partly checked box (a parent of some checked items) fills its left
48/// cell. [`CheckboxStyle::Check`] draws the older three-cell box with a check or a dash. Enter,
49/// Space or a click on the box or the label toggles it. The application owns the state.
50///
51/// Style keys: `checkbox` (`bg`, `fg`) with states `hover`, `focus`, `checked`, `disabled` and
52/// variant `partial` (check style); `checkbox-label` (`fg`, `bold`) with the same states.
53pub struct Checkbox<Msg> {
54    label: Option<String>,
55    checked: bool,
56    partial: bool,
57    style: CheckboxStyle,
58    disabled: bool,
59    on_toggle: Option<ToggleMessage<Msg>>,
60}
61
62impl<Msg> Checkbox<Msg> {
63    /// A checkbox showing `checked`.
64    #[must_use]
65    pub fn new(checked: bool) -> Self {
66        Self { label: None, checked, partial: false, style: CheckboxStyle::Box, disabled: false, on_toggle: None }
67    }
68
69    /// Text after the box; clicking it toggles too.
70    #[must_use]
71    pub fn label(mut self, label: impl Into<String>) -> Self {
72        self.label = Some(label.into());
73        self
74    }
75
76    /// Shows the box partly checked. Toggling a partly checked box asks for `true`.
77    #[must_use]
78    pub fn partial(mut self, partial: bool) -> Self {
79        self.partial = partial;
80        self
81    }
82
83    /// Chooses how the box looks.
84    #[must_use]
85    pub fn style(mut self, style: CheckboxStyle) -> Self {
86        self.style = style;
87        self
88    }
89
90    /// Greys the checkbox out; it cannot be focused or toggled.
91    #[must_use]
92    pub fn disabled(mut self, disabled: bool) -> Self {
93        self.disabled = disabled;
94        self
95    }
96
97    /// Message for the new state when the checkbox is toggled.
98    #[must_use]
99    pub fn on_toggle(mut self, message: impl Fn(bool) -> Msg + 'static) -> Self {
100        self.on_toggle = Some(Box::new(message));
101        self
102    }
103
104    fn active(&self) -> bool {
105        !self.disabled && self.on_toggle.is_some()
106    }
107
108    fn box_width(&self) -> u16 {
109        match self.style {
110            CheckboxStyle::Box => BOX,
111            CheckboxStyle::Check => CHECK_BOX,
112        }
113    }
114}
115
116/// The blend of every choice box a widget draws, by index.
117#[derive(Debug, Default)]
118struct BoxBlends(Vec<Tween>);
119
120/// How filled choice box `index` of this widget is, moving towards `target` (0 empty, 1 filled).
121/// A box starts at its first target without animating.
122fn blend(cx: &mut PaintCx<'_>, index: usize, target: f32) -> f32 {
123    if cx.reduced_motion() {
124        return target;
125    }
126    let now = cx.now();
127    let duration = cx.env().theme().motion().step * BLEND_STEPS;
128    let blends = &mut cx.memory::<BoxBlends>().0;
129    if blends.len() <= index {
130        blends.resize(index + 1, Tween::settled(target));
131    }
132    let tween = &mut blends[index];
133    if tween.target() != target {
134        tween.retarget(target, now, duration, Easing::Linear);
135    }
136    let (value, running) = (tween.value(now), tween.is_running(now));
137    if running {
138        cx.request_frame_in(BLEND_FRAME);
139    }
140    value
141}
142
143/// Paints a two-cell choice box at `(x, y)` from the `bg` of `widget`/`variant`: each cell mixes
144/// the empty colour (the states without `checked`) into the filled colour (with `checked`) by its
145/// fill. Blends are kept per `index` so a group of boxes animates each one on its own.
146pub(super) fn paint_box(
147    cx: &mut PaintCx<'_>,
148    at: (i32, i32),
149    style: (&str, Option<&str>),
150    states: &[State],
151    blends: (usize, [f32; 2]),
152) {
153    let (widget, variant) = style;
154    let mut empty_states: Vec<State> = states.iter().copied().filter(|s| *s != State::Checked).collect();
155    let empty = cx.style(widget, variant, &empty_states).text().bg.unwrap_or_else(|| cx.color("raised"));
156    empty_states.push(State::Checked);
157    let filled = cx.style(widget, variant, &empty_states).text().bg.unwrap_or_else(|| cx.color("accent"));
158    let (index, targets) = blends;
159    for (cell, target) in (0..BOX).zip(targets) {
160        let fill = blend(cx, index * usize::from(BOX) + usize::from(cell), target);
161        cx.clear(Rect::new(at.0 + i32::from(cell), at.1, 1, 1), empty.mix(filled, fill));
162    }
163}
164
165impl<Msg: 'static> Widget<Msg> for Checkbox<Msg> {
166    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
167        let label = self.label.as_deref().map_or(0, |label| text::width(label).saturating_add(LABEL_GAP));
168        Size::new(self.box_width().saturating_add(label), 1).min(available)
169    }
170
171    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
172        let mut states = if self.active() { cx.pressable_states() } else { Vec::new() };
173        if self.disabled {
174            states.push(State::Disabled);
175        }
176        if self.checked || self.partial {
177            states.push(State::Checked);
178        }
179        let box_width = self.box_width();
180        match self.style {
181            CheckboxStyle::Box => {
182                let left = if self.checked || self.partial { 1.0 } else { 0.0 };
183                let right = if self.checked { 1.0 } else { 0.0 };
184                paint_box(cx, (area.x, area.y), ("checkbox", None), &states, (0, [left, right]));
185            }
186            CheckboxStyle::Check => {
187                let variant = (self.partial && !self.checked).then_some("partial");
188                let style = cx.style("checkbox", variant, &states).text();
189                let background = style.bg.unwrap_or_else(|| cx.color("raised"));
190                cx.clear(Rect::new(area.x, area.y, box_width.min(area.width), 1), background);
191                if self.checked || self.partial {
192                    let key = if self.checked { "check" } else { "check-partial" };
193                    let glyph = cx.env().icons().glyph(key).into_owned();
194                    cx.text(area.x + 1, area.y, &glyph, CellStyle { bg: None, ..style }, 1);
195                }
196            }
197        }
198        if let Some(label) = &self.label {
199            let label_style = cx.style("checkbox-label", None, &states).text();
200            let budget = area.width.saturating_sub(box_width + LABEL_GAP);
201            let shown = text::truncate(label, budget).into_owned();
202            cx.text(area.x + i32::from(box_width + LABEL_GAP), area.y, &shown, label_style, budget);
203        }
204        if self.active() {
205            cx.register_hit(area);
206        }
207    }
208
209    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
210        if !self.active() {
211            return false;
212        }
213        match press::read(cx, event) {
214            Press::Ignored => false,
215            Press::Used => true,
216            Press::Key | Press::Click(..) => {
217                if let Some(message) = &self.on_toggle {
218                    cx.emit(message(!self.checked || self.partial));
219                }
220                true
221            }
222        }
223    }
224
225    fn focusable(&self) -> bool {
226        self.active()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::icons::GlyphMode;
234    use crate::runtime::{App, Command, Harness};
235    use crate::widget::View;
236
237    #[derive(Default)]
238    struct Demo {
239        checked: bool,
240        partial: bool,
241        style: CheckboxStyle,
242        disabled: bool,
243    }
244
245    impl App for Demo {
246        type Msg = bool;
247        fn update(&mut self, on: bool) -> Command<bool> {
248            self.checked = on;
249            self.partial = false;
250            Command::none()
251        }
252        fn view(&self, ui: &mut View<'_, bool>) {
253            ui.add(
254                Checkbox::new(self.checked)
255                    .partial(self.partial)
256                    .style(self.style)
257                    .disabled(self.disabled)
258                    .label("Autosave")
259                    .on_toggle(|on| on),
260            )
261            .id("box");
262        }
263    }
264
265    #[test]
266    fn the_default_box_is_two_cells_of_colour_that_fill_when_checked() {
267        let mut h = Harness::new(Demo::default(), 20, 1);
268        h.set_reduced_motion(true);
269        assert_eq!(h.screen(), "    Autosave\n", "no glyph, no bracket");
270        let theme = h.env().theme().clone();
271        assert_eq!(
272            (h.bg(0, 0), h.bg(1, 0), h.bg(2, 0)),
273            (theme.color("raised"), theme.color("raised"), theme.color("canvas"))
274        );
275        h.click_text("Autosave");
276        assert!(h.app().checked);
277        h.hover(19, 0);
278        assert_eq!(h.screen(), "    Autosave\n", "a checkbox shows no pillar");
279        assert_eq!((h.bg(0, 0), h.bg(1, 0)), (theme.color("accent"), theme.color("accent")));
280        h.press("tab").press("space");
281        assert!(!h.app().checked, "the keyboard toggles too");
282    }
283
284    #[test]
285    fn the_whole_label_area_toggles() {
286        let mut h = Harness::new(Demo::default(), 20, 1);
287        for x in [0, 1, 2, 3, 6, 11] {
288            let before = h.app().checked;
289            h.click(x, 0);
290            assert_ne!(h.app().checked, before, "a click at column {x} toggles");
291        }
292    }
293
294    #[test]
295    fn hover_lightens_the_empty_box_and_keyboard_focus_tints_it() {
296        let mut h = Harness::new(Demo::default(), 20, 1);
297        let theme = h.env().theme().clone();
298        let rest = h.bg(0, 0);
299        h.hover(6, 0);
300        assert_eq!(h.bg(0, 0), theme.color("active"));
301        assert_ne!(h.bg(0, 0), rest);
302        h.hover(19, 0);
303        assert_eq!(h.bg(0, 0), rest);
304        h.hover(40, 0).press("tab");
305        assert_ne!(h.bg(0, 0), rest, "keyboard focus shows on the box");
306        assert_ne!(h.bg(0, 0), theme.color("accent"), "focus is a tint, not the checked fill");
307    }
308
309    #[test]
310    fn checking_blends_the_colour_over_three_steps_and_reduced_motion_jumps() {
311        let mut h = Harness::new(Demo::default(), 20, 1);
312        let theme = h.env().theme().clone();
313        let step = theme.motion().step;
314        h.hover(40, 0);
315        h.send(true);
316        let (empty, filled) = (theme.color("raised").expect("raised"), theme.color("accent").expect("accent"));
317        assert_eq!(h.bg(0, 0), Some(empty), "the change starts from the empty tone");
318        h.advance(step * 3 / 2);
319        let middle = empty.mix(filled, 0.5);
320        let shown = h.bg(0, 0).expect("colour");
321        let close = |a: u8, b: u8| a.abs_diff(b) <= 3;
322        assert!(close(shown.r, middle.r) && close(shown.g, middle.g), "halfway is the middle colour: {shown:?}");
323        assert_eq!(h.bg(0, 0), h.bg(1, 0), "both cells blend together");
324        h.advance(step * 2);
325        assert_eq!(h.bg(0, 0), Some(filled));
326        h.set_reduced_motion(true);
327        h.send(false);
328        assert_eq!(h.bg(1, 0), Some(empty), "reduced motion empties at once");
329    }
330
331    #[test]
332    fn partial_fills_the_left_cell_and_asks_for_checked() {
333        let mut h = Harness::new(Demo { partial: true, ..Demo::default() }, 20, 1);
334        let theme = h.env().theme().clone();
335        assert_eq!(h.screen(), "    Autosave\n");
336        assert_eq!((h.bg(0, 0), h.bg(1, 0)), (theme.color("accent"), theme.color("raised")));
337        h.click_text("Autosave");
338        assert!(h.app().checked);
339    }
340
341    #[test]
342    fn ascii_mode_draws_the_same_box() {
343        let mut h = Harness::new(Demo { checked: true, ..Demo::default() }, 20, 1);
344        let unicode = (h.screen(), h.bg(0, 0), h.bg(1, 0));
345        h.set_glyph_mode(GlyphMode::Ascii);
346        assert_eq!((h.screen(), h.bg(0, 0), h.bg(1, 0)), unicode);
347    }
348
349    #[test]
350    fn disabled_uses_the_disabled_tones_and_ignores_presses() {
351        let mut h = Harness::new(Demo { checked: true, disabled: true, ..Demo::default() }, 20, 1);
352        let theme = h.env().theme().clone();
353        assert_eq!(h.bg(0, 0), theme.color("active"));
354        assert_eq!(h.fg(4, 0), theme.color("muted"));
355        h.click_text("Autosave").press("tab").press("space");
356        assert!(h.app().checked);
357        let h = Harness::new(Demo { disabled: true, ..Demo::default() }, 20, 1);
358        assert_eq!(h.bg(0, 0), theme.color("raised"));
359    }
360
361    #[test]
362    fn the_check_style_keeps_the_three_cell_box_with_a_mark() {
363        let mut h = Harness::new(Demo { style: CheckboxStyle::Check, ..Demo::default() }, 20, 1);
364        h.set_glyph_mode(GlyphMode::Unicode);
365        assert_eq!(h.screen(), "     Autosave\n");
366        assert_eq!(h.bg(1, 0), h.env().theme().color("raised"));
367        h.click_text("Autosave");
368        assert_eq!(h.screen(), " ✓   Autosave\n");
369        let mut h = Harness::new(Demo { style: CheckboxStyle::Check, partial: true, ..Demo::default() }, 20, 1);
370        h.set_glyph_mode(GlyphMode::Unicode);
371        assert_eq!(h.screen(), " –   Autosave\n");
372    }
373
374    #[test]
375    fn a_narrow_space_keeps_the_box_and_cuts_the_label() {
376        let h = Harness::new(Demo::default(), 8, 1);
377        assert_eq!(h.screen(), "    Aut…\n");
378    }
379}