//! The Checkbox primitive: a small box with a check mark, an optional label, //! and a controlled `checked` value. //! //! The checkbox owns *behavior and geometry* — it places the box, scales the //! mark into it, lays out the label, and toggles on click or Space — while //! its *appearance tokens* come from the theme: the box fill and border (with //! distinct checked variants the widget selects between by its own `checked` //! field), the check mark, and colors, all resolved per interaction state by //! the style pass. The always-present default theme supplies them. //! //! The mark is a [`Graphic`], so a theme may draw it with vector geometry or //! with a file's pixels. A path obeys `check-color` and `check-width`; an //! image carries its own color and varies across states by swapping the //! asset. //! //! `checked` follows the controlled-input contract text-input established: a //! programmatic [`set_checked`](Checkbox::set_checked) is equality-gated and //! never re-emits, so `:checked` bindings cannot loop. A click or Space flips //! the value and emits [`EventData::Toggled`] with the new state — the first //! non-`String` widget payload. use guiduck_scene::Fragment; use guiduck_scene::geom::{Point, Rect, Size, Stroke}; use guiduck_scene::paint::{Brush, Color}; use parley::{Alignment, AlignmentOptions, Layout, StyleProperty}; use taffy::AvailableSpace; use super::{CursorShape, Widget}; use crate::dirty::Dirt; use crate::event::{EventData, EventKind, Key, KeyInput, PointerEvent}; use crate::graphic::{Graphic, GraphicPaint}; use crate::style::{ComputedStyle, StyleReader}; use crate::text::TextContext; /// Side of the (square) check box. const BOX_SIZE: f64 = 18.0; /// Gap between the box and the label. const LABEL_GAP: f64 = 8.0; /// The appearance tokens a checkbox reads from the theme. struct Tokens { box_fill: Brush, box_fill_checked: Brush, box_border: Brush, box_border_checked: Brush, box_border_width: f64, box_corner_radius: f64, check_mark: Graphic, check_color: Brush, check_width: f64, label_color: Brush, focus_ring_color: Brush, focus_ring_width: f64, } impl Tokens { /// The pre-fill state; [`Default`] fills every slot from the fallback /// theme. fn blank() -> Self { Self { box_fill: Color::TRANSPARENT.into(), box_fill_checked: Color::TRANSPARENT.into(), box_border: Color::TRANSPARENT.into(), box_border_checked: Color::TRANSPARENT.into(), box_border_width: 0.0, box_corner_radius: 0.0, check_mark: Graphic::Path(Default::default()), check_color: Color::TRANSPARENT.into(), check_width: 0.0, label_color: Color::TRANSPARENT.into(), focus_ring_color: Color::TRANSPARENT.into(), focus_ring_width: 0.0, } } /// Read every token out of a computed style, reporting whether any /// changed. fn read(&mut self, style: &ComputedStyle) -> bool { let mut reader = StyleReader::new(style); reader.brush(&mut self.box_fill, "box-fill"); reader.brush(&mut self.box_fill_checked, "box-fill-checked"); reader.brush(&mut self.box_border, "box-border-color"); reader.brush(&mut self.box_border_checked, "box-border-color-checked"); reader.number(&mut self.box_border_width, "box-border-width"); reader.number(&mut self.box_corner_radius, "box-corner-radius"); reader.graphic(&mut self.check_mark, "check-mark"); reader.brush(&mut self.check_color, "check-color"); reader.number(&mut self.check_width, "check-width"); reader.brush(&mut self.label_color, "color"); reader.brush(&mut self.focus_ring_color, "focus-ring-color"); reader.number(&mut self.focus_ring_width, "focus-ring-width"); reader.changed() } } impl Default for Tokens { fn default() -> Self { let mut tokens = Self::blank(); tokens.read(&super::fallback_style("checkbox")); tokens } } pub struct Checkbox { checked: bool, /// Radio behaviour: choosing is one-way, and the event says *chosen* /// rather than *toggled*. See [`Radio`], which is how a file asks for it. radio: bool, label: Option, font_size: f32, family: Option, layout: Option>, tokens: Tokens, dirt: Dirt, emitted: Vec, } impl Checkbox { pub fn new() -> Self { Self { checked: false, radio: false, label: None, font_size: 14.0, family: None, layout: None, tokens: Tokens::default(), dirt: Dirt::CLEAN, emitted: Vec::new(), } } pub fn checked(mut self, checked: bool) -> Self { self.set_checked(checked); self } pub fn label(mut self, label: impl Into) -> Self { self.set_label(label); self } pub fn font_size(mut self, size: f32) -> Self { self.set_font_size(size); self } /// Use a specific font family instead of the collection's default. pub fn family(mut self, family: impl Into) -> Self { self.set_family(family); self } /// The resolved box radius — how a test sees that a theme reached this /// widget without reaching into its private tokens. #[cfg(test)] pub(crate) fn box_corner_radius_for_test(&self) -> f64 { self.tokens.box_corner_radius } pub fn is_checked(&self) -> bool { self.checked } /// Set the checked state programmatically. Equality-gated and silent — /// the controlled-input contract, so `:checked` bindings never loop. pub fn set_checked(&mut self, checked: bool) { if self.checked != checked { self.checked = checked; self.dirt.mark_paint(); } } pub fn set_label(&mut self, label: impl Into) { let label = Some(label.into()); if self.label != label { self.label = label; self.layout = None; self.dirt.mark_layout(); } } pub fn set_font_size(&mut self, size: f32) { if self.font_size != size { self.font_size = size; self.layout = None; self.dirt.mark_layout(); } } pub fn set_family(&mut self, family: impl Into) { let family = Some(family.into()); if self.family != family { self.family = family; self.layout = None; self.dirt.mark_layout(); } } /// Act on user input and queue the event that says what happened. /// /// A radio is one-way: choosing the already-chosen one is not a change, /// so it reports nothing. That is the behaviour a theme rule cannot give /// a checkbox, and half the reason a radio is its own widget — the other /// half being that assistive technology is told which it is. fn activate(&mut self) { if self.radio { if self.checked { return; } self.checked = true; self.emitted.push(EventData::Selected); } else { self.checked = !self.checked; self.emitted.push(EventData::Toggled(self.checked)); } self.dirt.mark_paint(); } fn layout_at(&mut self, text_cx: &mut TextContext) -> Option<&Layout> { let label = self.label.as_ref()?; if self.layout.is_none() { let mut builder = text_cx .layout_cx .ranged_builder(&mut text_cx.font_cx, label, 1.0, true); if let Some(family) = &self.family { builder.push_default(StyleProperty::FontFamily(crate::text::font_family(family))); } builder.push_default(StyleProperty::FontSize(self.font_size)); builder.push_default(StyleProperty::Brush(self.tokens.label_color.clone())); let mut layout = builder.build(label); layout.break_all_lines(None); layout.align(Alignment::Start, AlignmentOptions::default()); self.layout = Some(layout); } self.layout.as_ref() } } impl Default for Checkbox { fn default() -> Self { Self::new() } } impl Widget for Checkbox { fn measure( &mut self, text_cx: &mut TextContext, _known: taffy::Size>, _available: taffy::Size, ) -> taffy::Size { let (label_w, label_h) = match self.layout_at(text_cx) { Some(layout) => ( LABEL_GAP as f32 + layout.width(), layout.height().max(BOX_SIZE as f32), ), None => (0.0, BOX_SIZE as f32), }; taffy::Size { width: (BOX_SIZE as f32 + label_w).ceil(), height: label_h.ceil(), } } fn finalize_layout(&mut self, text_cx: &mut TextContext, _size: Size, _content_size: Size) { self.layout_at(text_cx); } fn paint(&mut self, fragment: &mut Fragment, size: Size) { let t = &self.tokens; let box_top = ((size.height - BOX_SIZE) / 2.0).max(0.0); let box_rect = Rect::from_origin_size((0.0, box_top), Size::new(BOX_SIZE, BOX_SIZE)); // The widget owns which token applies for its internal `checked` // state; the theme owns the tokens themselves. let (fill, border) = if self.checked { (&t.box_fill_checked, &t.box_border_checked) } else { (&t.box_fill, &t.box_border) }; fragment.fill(box_rect.to_rounded_rect(t.box_corner_radius), fill.clone()); let inset = box_rect.inset(-t.box_border_width / 2.0); fragment.stroke( inset.to_rounded_rect(t.box_corner_radius), border.clone(), Stroke::new(t.box_border_width), ); if !super::is_transparent(&t.focus_ring_color) { // The `:focus` rule makes the ring color opaque; the widget stays // interaction-state-agnostic. let ring = box_rect.inset(t.focus_ring_width / 2.0 + 1.5); fragment.stroke( ring.to_rounded_rect(t.box_corner_radius + 2.0), t.focus_ring_color.clone(), Stroke::new(t.focus_ring_width), ); } if self.checked { // Whether there is a mark at all is the widget's call — it reads // its own `checked`. What the mark *is* belongs to the theme, and // may be geometry or pixels; a stroke is how this widget paints a // path, and an image ignores it. t.check_mark.paint_into( fragment, box_rect, &GraphicPaint::Stroke(t.check_color.clone(), t.check_width), ); } if let Some(layout) = &self.layout { let origin = Point::new( BOX_SIZE + LABEL_GAP, (size.height - layout.height() as f64) / 2.0, ); crate::text::append_layout(fragment, layout, origin); } } fn role(&self) -> accesskit::Role { if self.radio { accesskit::Role::RadioButton } else { accesskit::Role::CheckBox } } fn accessibility(&self, node: &mut accesskit::Node) { if let Some(label) = &self.label { node.set_label(label.clone()); } node.set_toggled(if self.checked { accesskit::Toggled::True } else { accesskit::Toggled::False }); } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn take_emitted(&mut self) -> Vec { std::mem::take(&mut self.emitted) } fn cursor(&self, _local: Point) -> CursorShape { CursorShape::Pointer } fn type_name(&self) -> &'static str { "checkbox" } fn focusable(&self) -> bool { true } fn on_key( &mut self, key: &KeyInput, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { if !key.pressed { return false; } match &key.key { Key::Character(text) if text == " " => { self.activate(); true } _ => false, } } fn on_pointer( &mut self, kind: EventKind, _event: &PointerEvent, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { // A completed click (press and release over the box) toggles. if kind == EventKind::Click { self.activate(); true } else { false } } /// A click on the checkbox is the checkbox's; it does not also fire an /// enclosing container's `:on-click`. fn consumes_click(&self) -> bool { true } fn apply_style(&mut self, style: &ComputedStyle) { let previous_label_color = self.tokens.label_color.clone(); if self.tokens.read(style) { if self.tokens.label_color != previous_label_color { // The label color is baked into the parley layout; a change // rebuilds it at the same width (identical geometry, so // paint-only). self.layout = None; } self.dirt.mark_paint(); } if let Some(size) = style.number("font-size") { self.set_font_size(size as f32); } if let Some(family) = style.string("font-family") { self.set_family(family.to_owned()); } } } #[cfg(test)] mod tests; /// Define a control that is a [`Checkbox`] wearing a different name. /// /// Four of these exist, and they differ in exactly three things: the name a /// theme matches, the role assistive technology is told, and whether choosing /// is one-way. Everything else — drawing, measuring, styling, the keyboard, /// the controlled contract — is the checkbox's, so it is written once here /// rather than four times. Only `type_name` and `role` are ever overridden, /// which is why they are the macro's parameters and not an escape hatch: /// delegating `type_name` by accident would silently make every one of these /// theme as a checkbox. macro_rules! checkbox_adapter { ( $(#[$meta:meta])* $name:ident, $type_name:literal, $role:expr, one_way = $one_way:literal, $setter:ident / $getter:ident ) => { $(#[$meta])* pub struct $name(Checkbox); impl Default for $name { fn default() -> Self { Self(Checkbox { radio: $one_way, ..Checkbox::new() }) } } impl $name { pub fn new() -> Self { Self::default() } pub fn label(mut self, label: impl Into) -> Self { self.set_label(label); self } pub fn set_label(&mut self, label: impl Into) { self.0.set_label(label); } pub fn set_font_size(&mut self, size: f32) { self.0.set_font_size(size); } pub fn set_family(&mut self, family: impl Into) { self.0.set_family(family); } pub fn $setter(&mut self, on: bool) { self.0.set_checked(on); } pub fn $getter(&self) -> bool { self.0.is_checked() } #[cfg(test)] #[allow(dead_code, reason = "used by whichever adapter a test reaches for")] pub(crate) fn box_corner_radius_for_test(&self) -> f64 { self.0.box_corner_radius_for_test() } } impl Widget for $name { fn measure( &mut self, text_cx: &mut TextContext, known: taffy::Size>, available: taffy::Size, ) -> taffy::Size { self.0.measure(text_cx, known, available) } fn finalize_layout( &mut self, text_cx: &mut TextContext, size: Size, content_size: Size, ) { self.0.finalize_layout(text_cx, size, content_size); } fn paint(&mut self, fragment: &mut Fragment, size: Size) { self.0.paint(fragment, size); } fn type_name(&self) -> &'static str { $type_name } fn role(&self) -> accesskit::Role { $role } fn accessibility(&self, node: &mut accesskit::Node) { self.0.accessibility(node); } fn take_dirt(&mut self) -> Dirt { self.0.take_dirt() } fn take_emitted(&mut self) -> Vec { self.0.take_emitted() } fn cursor(&self, local: Point) -> CursorShape { self.0.cursor(local) } fn focusable(&self) -> bool { self.0.focusable() } fn on_key( &mut self, key: &KeyInput, text_cx: &mut TextContext, clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { self.0.on_key(key, text_cx, clipboard) } fn on_pointer( &mut self, kind: EventKind, event: &PointerEvent, text_cx: &mut TextContext, clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { self.0.on_pointer(kind, event, text_cx, clipboard) } fn consumes_click(&self) -> bool { self.0.consumes_click() } fn apply_style(&mut self, style: &ComputedStyle) { self.0.apply_style(style); } } }; } checkbox_adapter!( /// A radio button. /// /// Its own widget rather than a themed checkbox because two of the three /// differences are ones a theme cannot reach: assistive technology is told /// it is a radio, and choosing the already-chosen one is not a change, so /// it reports nothing and does not flip off. The look is the third. /// /// It holds no group — mutual exclusion is a fact about the application's /// data, so `:checked` binds to a comparison and the wire carries identity. /// /// ```lisp /// (for option options :key option.id /// (radio :label option.name /// :checked (= option.id chosen) /// :on-select (choose option.id))) /// ``` Radio, "radio", accesskit::Role::RadioButton, one_way = true, set_checked / is_checked ); checkbox_adapter!( /// A switch: on or off rather than ticked or not. /// /// Behaviourally a checkbox — a switch flips both ways — so it reports /// `on-toggle`. What it adds is the announcement. Switch, "switch", accesskit::Role::Switch, one_way = false, set_checked / is_checked ); checkbox_adapter!( /// One tab in a strip: one of a set, chosen rather than toggled. /// /// `selected` rather than `checked`, because a tab is showing or not and /// calling that "checked" would be borrowing a checkbox's word for it. Tab, "tab", accesskit::Role::Tab, one_way = true, set_selected / is_selected ); checkbox_adapter!( /// One row of a list. ListItem, "list-item", accesskit::Role::ListItem, one_way = true, set_selected / is_selected );