1use 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#[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 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 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 pub fn with_checked(mut self, checked: bool) -> Self {
76 self.checked = checked;
77 self
78 }
79
80 pub fn with_role(mut self, role: Role) -> Self {
83 self.role = role;
84 self
85 }
86
87 pub fn with_style(mut self, style: TextStyle) -> Self {
89 self.style = style;
90 self
91 }
92
93 pub fn with_size(mut self, size_px: u16) -> Self {
95 self.style.size_px = size_px;
96 self
97 }
98
99 #[inline]
101 pub const fn checked(&self) -> bool {
102 self.checked
103 }
104
105 pub fn set_checked(&mut self, checked: bool) {
111 self.checked = checked;
112 }
113
114 #[inline]
116 pub fn label(&self) -> &str {
117 &self.label
118 }
119
120 pub fn set_label(&mut self, label: impl Into<String>) {
122 self.label = label.into();
123 }
124
125 pub fn set_role(&mut self, role: Role) {
127 self.role = role;
128 }
129
130 pub fn set_style(&mut self, style: TextStyle) {
136 self.style = style;
137 }
138
139 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#[inline]
158const fn gap(side: i32) -> i32 {
159 if side < 2 { 1 } else { side / 2 }
162}
163
164fn 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#[inline]
185const fn tick_weight(side: i32) -> i32 {
186 if side / 8 < 2 { 2 } else { side / 8 }
187}
188
189fn draw_tick(canvas: &mut Pen<'_>, area: Rect, color: denise::Color, thickness: i32) {
199 let s = area.width;
200 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 let radius = ctx.theme.radius(Radius::Selector).min(area.width / 2);
242
243 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 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 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 "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 "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 #[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 #[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 #[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 #[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 #[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 }
475}