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;
53/// `checkbox-description` (`fg`) with the same states.
54pub struct Checkbox<Msg> {
55    label: Option<String>,
56    description: Option<String>,
57    checked: bool,
58    partial: bool,
59    style: CheckboxStyle,
60    disabled: bool,
61    on_toggle: Option<ToggleMessage<Msg>>,
62}
63
64impl<Msg> Checkbox<Msg> {
65    /// A checkbox showing `checked`.
66    #[must_use]
67    pub fn new(checked: bool) -> Self {
68        Self {
69            label: None,
70            description: None,
71            checked,
72            partial: false,
73            style: CheckboxStyle::Box,
74            disabled: false,
75            on_toggle: None,
76        }
77    }
78
79    /// Text after the box; clicking it toggles too.
80    #[must_use]
81    pub fn label(mut self, label: impl Into<String>) -> Self {
82        self.label = Some(label.into());
83        self
84    }
85
86    /// A faint explanation under the label, starting in the label's column and wrapping there,
87    /// such as what checking the box will do. Clicking it toggles the box, as the label does.
88    #[must_use]
89    pub fn description(mut self, text: impl Into<String>) -> Self {
90        self.description = Some(text.into());
91        self
92    }
93
94    /// Cells from the checkbox's left edge to the first letter of its label: where text that
95    /// belongs to the label, drawn outside the checkbox, lines up with it.
96    #[must_use]
97    pub fn label_column(&self) -> u16 {
98        self.box_width().saturating_add(LABEL_GAP)
99    }
100
101    /// Shows the box partly checked. Toggling a partly checked box asks for `true`.
102    #[must_use]
103    pub fn partial(mut self, partial: bool) -> Self {
104        self.partial = partial;
105        self
106    }
107
108    /// Chooses how the box looks.
109    #[must_use]
110    pub fn style(mut self, style: CheckboxStyle) -> Self {
111        self.style = style;
112        self
113    }
114
115    /// Greys the checkbox out; it cannot be focused or toggled.
116    #[must_use]
117    pub fn disabled(mut self, disabled: bool) -> Self {
118        self.disabled = disabled;
119        self
120    }
121
122    /// Message for the new state when the checkbox is toggled.
123    #[must_use]
124    pub fn on_toggle(mut self, message: impl Fn(bool) -> Msg + 'static) -> Self {
125        self.on_toggle = Some(Box::new(message));
126        self
127    }
128
129    fn active(&self) -> bool {
130        !self.disabled && self.on_toggle.is_some()
131    }
132
133    /// The description wrapped to the room right of the label's column in `width` cells.
134    fn description_lines(&self, width: u16) -> Vec<String> {
135        let room = width.saturating_sub(self.label_column()).max(1);
136        self.description.as_deref().map_or_else(Vec::new, |description| text::wrap(description, room))
137    }
138
139    fn box_width(&self) -> u16 {
140        match self.style {
141            CheckboxStyle::Box => BOX,
142            CheckboxStyle::Check => CHECK_BOX,
143        }
144    }
145}
146
147/// The blend of every choice box a widget draws, by index.
148#[derive(Debug, Default)]
149struct BoxBlends(Vec<Tween>);
150
151/// How filled choice box `index` of this widget is, moving towards `target` (0 empty, 1 filled).
152/// A box starts at its first target without animating.
153fn blend(cx: &mut PaintCx<'_>, index: usize, target: f32) -> f32 {
154    if cx.reduced_motion() {
155        return target;
156    }
157    let now = cx.now();
158    let duration = cx.env().theme().motion().step * BLEND_STEPS;
159    let blends = &mut cx.memory::<BoxBlends>().0;
160    if blends.len() <= index {
161        blends.resize(index + 1, Tween::settled(target));
162    }
163    let tween = &mut blends[index];
164    if tween.target() != target {
165        tween.retarget(target, now, duration, Easing::Linear);
166    }
167    let (value, running) = (tween.value(now), tween.is_running(now));
168    if running {
169        cx.request_frame_in(BLEND_FRAME);
170    }
171    value
172}
173
174/// Paints a two-cell choice box at `(x, y)` from the `bg` of `widget`/`variant`: each cell mixes
175/// the empty colour (the states without `checked`) into the filled colour (with `checked`) by its
176/// fill. Blends are kept per `index` so a group of boxes animates each one on its own.
177pub(super) fn paint_box(
178    cx: &mut PaintCx<'_>,
179    at: (i32, i32),
180    style: (&str, Option<&str>),
181    states: &[State],
182    blends: (usize, [f32; 2]),
183) {
184    let (widget, variant) = style;
185    let mut empty_states: Vec<State> = states.iter().copied().filter(|s| *s != State::Checked).collect();
186    let empty = cx.style(widget, variant, &empty_states).text().bg.unwrap_or_else(|| cx.color("raised"));
187    empty_states.push(State::Checked);
188    let filled = cx.style(widget, variant, &empty_states).text().bg.unwrap_or_else(|| cx.color("accent"));
189    let (index, targets) = blends;
190    for (cell, target) in (0..BOX).zip(targets) {
191        let fill = blend(cx, index * usize::from(BOX) + usize::from(cell), target);
192        cx.clear(Rect::new(at.0 + i32::from(cell), at.1, 1, 1), empty.mix(filled, fill));
193    }
194}
195
196impl<Msg: 'static> Widget<Msg> for Checkbox<Msg> {
197    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
198        let label = self.label.as_deref().map_or(0, |label| text::width(label).saturating_add(LABEL_GAP));
199        let lines = self.description_lines(available.width);
200        let described = lines
201            .iter()
202            .map(|line| text::width(line))
203            .max()
204            .map_or(0, |width| width.saturating_add(self.label_column()));
205        let height = u16::try_from(lines.len()).unwrap_or(u16::MAX).saturating_add(1);
206        Size::new(self.box_width().saturating_add(label).max(described), height).min(available)
207    }
208
209    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
210        let mut states = if self.active() { cx.pressable_states() } else { Vec::new() };
211        if self.disabled {
212            states.push(State::Disabled);
213        }
214        if self.checked || self.partial {
215            states.push(State::Checked);
216        }
217        let box_width = self.box_width();
218        match self.style {
219            CheckboxStyle::Box => {
220                let left = if self.checked || self.partial { 1.0 } else { 0.0 };
221                let right = if self.checked { 1.0 } else { 0.0 };
222                paint_box(cx, (area.x, area.y), ("checkbox", None), &states, (0, [left, right]));
223            }
224            CheckboxStyle::Check => {
225                let variant = (self.partial && !self.checked).then_some("partial");
226                let style = cx.style("checkbox", variant, &states).text();
227                let background = style.bg.unwrap_or_else(|| cx.color("raised"));
228                cx.clear(Rect::new(area.x, area.y, box_width.min(area.width), 1), background);
229                if self.checked || self.partial {
230                    let key = if self.checked { "check" } else { "check-partial" };
231                    let glyph = cx.env().icons().glyph(key).into_owned();
232                    cx.text(area.x + 1, area.y, &glyph, CellStyle { bg: None, ..style }, 1);
233                }
234            }
235        }
236        if let Some(label) = &self.label {
237            let label_style = cx.style("checkbox-label", None, &states).text();
238            let budget = area.width.saturating_sub(box_width + LABEL_GAP);
239            let shown = text::truncate(label, budget).into_owned();
240            cx.text(area.x + i32::from(box_width + LABEL_GAP), area.y, &shown, label_style, budget);
241        }
242        let description_style = cx.style("checkbox-description", None, &states).text();
243        let column = area.x + i32::from(self.label_column());
244        let budget = area.width.saturating_sub(self.label_column());
245        for (y, line) in (area.y + 1..area.bottom()).zip(self.description_lines(area.width)) {
246            cx.text(column, y, &line, description_style, budget);
247        }
248        if self.active() {
249            cx.register_hit(area);
250        }
251    }
252
253    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
254        if !self.active() {
255            return false;
256        }
257        match press::read(cx, event) {
258            Press::Ignored => false,
259            Press::Used => true,
260            Press::Key | Press::Click(..) => {
261                if let Some(message) = &self.on_toggle {
262                    cx.emit(message(!self.checked || self.partial));
263                }
264                true
265            }
266        }
267    }
268
269    fn focusable(&self) -> bool {
270        self.active()
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::icons::GlyphMode;
278    use crate::runtime::{App, Command, Harness};
279    use crate::widget::View;
280
281    #[derive(Default)]
282    struct Demo {
283        checked: bool,
284        partial: bool,
285        style: CheckboxStyle,
286        disabled: bool,
287    }
288
289    impl App for Demo {
290        type Msg = bool;
291        fn update(&mut self, on: bool) -> Command<bool> {
292            self.checked = on;
293            self.partial = false;
294            Command::none()
295        }
296        fn view(&self, ui: &mut View<'_, bool>) {
297            ui.add(
298                Checkbox::new(self.checked)
299                    .partial(self.partial)
300                    .style(self.style)
301                    .disabled(self.disabled)
302                    .label("Autosave")
303                    .on_toggle(|on| on),
304            )
305            .id("box");
306        }
307    }
308
309    /// A checkbox with a description, in the style `style`.
310    struct Described {
311        checked: bool,
312        style: CheckboxStyle,
313    }
314
315    impl App for Described {
316        type Msg = bool;
317        fn update(&mut self, on: bool) -> Command<bool> {
318            self.checked = on;
319            Command::none()
320        }
321        fn view(&self, ui: &mut View<'_, bool>) {
322            ui.add(
323                Checkbox::new(self.checked)
324                    .style(self.style)
325                    .label("Keep backups")
326                    .description("Old copies stay in the backup folder for thirty days")
327                    .on_toggle(|on| on),
328            );
329        }
330    }
331
332    fn column_of(h: &Harness<Described>, word: &str) -> (i32, i32) {
333        h.find(word).unwrap_or_else(|| panic!("{word}:\n{}", h.screen()))
334    }
335
336    #[test]
337    fn a_description_starts_in_the_labels_column_and_wraps_there() {
338        for style in [CheckboxStyle::Box, CheckboxStyle::Check] {
339            let h = Harness::new(Described { checked: false, style }, 30, 5);
340            let (label, row) = column_of(&h, "Keep");
341            let (old, below) = column_of(&h, "Old");
342            assert_eq!((old, below), (label, row + 1), "{style:?}:\n{}", h.screen());
343            let (days, last) = column_of(&h, "days");
344            assert!(last > below, "a narrow box wraps the description:\n{}", h.screen());
345            assert!(days >= label, "and every line keeps to the column:\n{}", h.screen());
346            assert_eq!(i32::from(Checkbox::<bool>::new(false).style(style).label_column()), label);
347        }
348    }
349
350    #[test]
351    fn clicking_the_description_toggles_the_box() {
352        let mut h = Harness::new(Described { checked: false, style: CheckboxStyle::Box }, 60, 3);
353        let (x, y) = column_of(&h, "thirty");
354        h.click(x, y);
355        assert!(h.app().checked);
356    }
357
358    #[test]
359    fn the_default_box_is_two_cells_of_colour_that_fill_when_checked() {
360        let mut h = Harness::new(Demo::default(), 20, 1);
361        h.set_reduced_motion(true);
362        assert_eq!(h.screen(), "    Autosave\n", "no glyph, no bracket");
363        let theme = h.env().theme().clone();
364        assert_eq!(
365            (h.bg(0, 0), h.bg(1, 0), h.bg(2, 0)),
366            (theme.color("raised"), theme.color("raised"), theme.color("canvas"))
367        );
368        h.click_text("Autosave");
369        assert!(h.app().checked);
370        h.hover(19, 0);
371        assert_eq!(h.screen(), "    Autosave\n", "a checkbox shows no pillar");
372        assert_eq!((h.bg(0, 0), h.bg(1, 0)), (theme.color("accent"), theme.color("accent")));
373        h.press("tab").press("space");
374        assert!(!h.app().checked, "the keyboard toggles too");
375    }
376
377    #[test]
378    fn the_whole_label_area_toggles() {
379        let mut h = Harness::new(Demo::default(), 20, 1);
380        for x in [0, 1, 2, 3, 6, 11] {
381            let before = h.app().checked;
382            h.click(x, 0);
383            assert_ne!(h.app().checked, before, "a click at column {x} toggles");
384        }
385    }
386
387    #[test]
388    fn hover_lightens_the_empty_box_and_keyboard_focus_tints_it() {
389        let mut h = Harness::new(Demo::default(), 20, 1);
390        let theme = h.env().theme().clone();
391        let rest = h.bg(0, 0);
392        h.hover(6, 0);
393        assert_eq!(h.bg(0, 0), theme.color("active"));
394        assert_ne!(h.bg(0, 0), rest);
395        h.hover(19, 0);
396        assert_eq!(h.bg(0, 0), rest);
397        h.hover(40, 0).press("tab");
398        assert_ne!(h.bg(0, 0), rest, "keyboard focus shows on the box");
399        assert_ne!(h.bg(0, 0), theme.color("accent"), "focus is a tint, not the checked fill");
400    }
401
402    #[test]
403    fn checking_blends_the_colour_over_three_steps_and_reduced_motion_jumps() {
404        let mut h = Harness::new(Demo::default(), 20, 1);
405        let theme = h.env().theme().clone();
406        let step = theme.motion().step;
407        h.hover(40, 0);
408        h.send(true);
409        let (empty, filled) = (theme.color("raised").expect("raised"), theme.color("accent").expect("accent"));
410        assert_eq!(h.bg(0, 0), Some(empty), "the change starts from the empty tone");
411        h.advance(step * 3 / 2);
412        let middle = empty.mix(filled, 0.5);
413        let shown = h.bg(0, 0).expect("colour");
414        let close = |a: u8, b: u8| a.abs_diff(b) <= 3;
415        assert!(close(shown.r, middle.r) && close(shown.g, middle.g), "halfway is the middle colour: {shown:?}");
416        assert_eq!(h.bg(0, 0), h.bg(1, 0), "both cells blend together");
417        h.advance(step * 2);
418        assert_eq!(h.bg(0, 0), Some(filled));
419        h.set_reduced_motion(true);
420        h.send(false);
421        assert_eq!(h.bg(1, 0), Some(empty), "reduced motion empties at once");
422    }
423
424    #[test]
425    fn partial_fills_the_left_cell_and_asks_for_checked() {
426        let mut h = Harness::new(Demo { partial: true, ..Demo::default() }, 20, 1);
427        let theme = h.env().theme().clone();
428        assert_eq!(h.screen(), "    Autosave\n");
429        assert_eq!((h.bg(0, 0), h.bg(1, 0)), (theme.color("accent"), theme.color("raised")));
430        h.click_text("Autosave");
431        assert!(h.app().checked);
432    }
433
434    #[test]
435    fn ascii_mode_draws_the_same_box() {
436        let mut h = Harness::new(Demo { checked: true, ..Demo::default() }, 20, 1);
437        let unicode = (h.screen(), h.bg(0, 0), h.bg(1, 0));
438        h.set_glyph_mode(GlyphMode::Ascii);
439        assert_eq!((h.screen(), h.bg(0, 0), h.bg(1, 0)), unicode);
440    }
441
442    #[test]
443    fn disabled_uses_the_disabled_tones_and_ignores_presses() {
444        let mut h = Harness::new(Demo { checked: true, disabled: true, ..Demo::default() }, 20, 1);
445        let theme = h.env().theme().clone();
446        assert_eq!(h.bg(0, 0), theme.color("active"));
447        assert_eq!(h.fg(4, 0), theme.color("muted"));
448        h.click_text("Autosave").press("tab").press("space");
449        assert!(h.app().checked);
450        let h = Harness::new(Demo { disabled: true, ..Demo::default() }, 20, 1);
451        assert_eq!(h.bg(0, 0), theme.color("raised"));
452    }
453
454    #[test]
455    fn the_check_style_keeps_the_three_cell_box_with_a_mark() {
456        let mut h = Harness::new(Demo { style: CheckboxStyle::Check, ..Demo::default() }, 20, 1);
457        h.set_glyph_mode(GlyphMode::Unicode);
458        assert_eq!(h.screen(), "     Autosave\n");
459        assert_eq!(h.bg(1, 0), h.env().theme().color("raised"));
460        h.click_text("Autosave");
461        assert_eq!(h.screen(), " ✓   Autosave\n");
462        let mut h = Harness::new(Demo { style: CheckboxStyle::Check, partial: true, ..Demo::default() }, 20, 1);
463        h.set_glyph_mode(GlyphMode::Unicode);
464        assert_eq!(h.screen(), " –   Autosave\n");
465    }
466
467    #[test]
468    fn a_narrow_space_keeps_the_box_and_cuts_the_label() {
469        let h = Harness::new(Demo::default(), 8, 1);
470        assert_eq!(h.screen(), "    Aut…\n");
471    }
472}