1use 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
15pub(super) const BOX: u16 = 2;
17
18const CHECK_BOX: u16 = 3;
20
21pub(super) const LABEL_GAP: u16 = 2;
23
24const BLEND_STEPS: u32 = 3;
27
28const BLEND_FRAME: Duration = Duration::from_millis(16);
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum CheckboxStyle {
34 #[default]
38 Box,
39 Check,
41}
42
43pub 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 #[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 #[must_use]
71 pub fn label(mut self, label: impl Into<String>) -> Self {
72 self.label = Some(label.into());
73 self
74 }
75
76 #[must_use]
78 pub fn partial(mut self, partial: bool) -> Self {
79 self.partial = partial;
80 self
81 }
82
83 #[must_use]
85 pub fn style(mut self, style: CheckboxStyle) -> Self {
86 self.style = style;
87 self
88 }
89
90 #[must_use]
92 pub fn disabled(mut self, disabled: bool) -> Self {
93 self.disabled = disabled;
94 self
95 }
96
97 #[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#[derive(Debug, Default)]
118struct BoxBlends(Vec<Tween>);
119
120fn 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
143pub(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}