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
);