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> {
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 #[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 #[must_use]
81 pub fn label(mut self, label: impl Into<String>) -> Self {
82 self.label = Some(label.into());
83 self
84 }
85
86 #[must_use]
89 pub fn description(mut self, text: impl Into<String>) -> Self {
90 self.description = Some(text.into());
91 self
92 }
93
94 #[must_use]
97 pub fn label_column(&self) -> u16 {
98 self.box_width().saturating_add(LABEL_GAP)
99 }
100
101 #[must_use]
103 pub fn partial(mut self, partial: bool) -> Self {
104 self.partial = partial;
105 self
106 }
107
108 #[must_use]
110 pub fn style(mut self, style: CheckboxStyle) -> Self {
111 self.style = style;
112 self
113 }
114
115 #[must_use]
117 pub fn disabled(mut self, disabled: bool) -> Self {
118 self.disabled = disabled;
119 self
120 }
121
122 #[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 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#[derive(Debug, Default)]
149struct BoxBlends(Vec<Tween>);
150
151fn 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
174pub(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 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}