checkbox.rs
raw
//! 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<String>,
font_size: f32,
family: Option<String>,
layout: Option<Layout<Brush>>,
tokens: Tokens,
dirt: Dirt,
emitted: Vec<EventData>,
}
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<String>) -> 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<String>) -> 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<String>) {
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<String>) {
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<Brush>> {
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<Option<f32>>,
_available: taffy::Size<AvailableSpace>,
) -> taffy::Size<f32> {
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<EventData> {
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<String>) -> Self {
self.set_label(label);
self
}
pub fn set_label(&mut self, label: impl Into<String>) {
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<String>) {
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<Option<f32>>,
available: taffy::Size<taffy::AvailableSpace>,
) -> taffy::Size<f32> {
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<EventData> {
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
);