button.rs raw

//! The Button primitive: a push button with a centered label and keyboard
//! activation.
//!
//! The button owns *behavior and geometry* — it lays the label out, places
//! the background and focus ring, and turns Enter/Space into activation —
//! while its *appearance tokens* (background, label color, corner radius,
//! focus-ring color and width) come from the theme, resolved per interaction
//! state by the style pass and delivered through [`Widget::apply_style`]. The
//! always-present default theme supplies them, so a button looks right with
//! no application theme; an app theme overrides selectively.
//!
//! Clicks reach applications through the ordinary `:on-click` wire; Enter and
//! Space while focused emit [`EventData::Activated`], which the tree converts
//! into a synthesized click at the button's center, so keyboard and pointer
//! activation are one path.

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, Key, KeyInput};
use crate::style::{ComputedStyle, StyleReader};
use crate::text::TextContext;

/// Inner padding between the button edge and the label.
const PAD_X: f64 = 14.0;
const PAD_Y: f64 = 7.0;

/// The appearance tokens a button reads from the theme.
struct Tokens {
    background: Brush,
    label_color: Brush,
    corner_radius: f64,
    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 {
            background: Color::TRANSPARENT.into(),
            label_color: Color::TRANSPARENT.into(),
            corner_radius: 0.0,
            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.background, "background");
        reader.brush(&mut self.label_color, "color");
        reader.number(&mut self.corner_radius, "corner-radius");
        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("button"));
        tokens
    }
}

pub struct Button {
    label: String,
    font_size: f32,
    family: Option<String>,
    layout: Option<Layout<Brush>>,
    tokens: Tokens,
    dirt: Dirt,
    emitted: Vec<EventData>,
}

impl Default for Button {
    fn default() -> Self {
        Self {
            label: String::new(),
            font_size: 14.0,
            family: None,
            layout: None,
            tokens: Tokens::default(),
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
        }
    }
}

impl Button {
    pub fn new(label: impl Into<String>) -> Self {
        let mut button = Self::default();
        button.set_label(label);
        button
    }

    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
    }

    pub fn label(&self) -> &str {
        &self.label
    }

    pub fn set_label(&mut self, label: impl Into<String>) {
        let label = label.into();
        if self.label == label {
            return;
        }
        self.label = label;
        self.layout = None;
        self.dirt.mark_layout();
    }

    pub fn set_font_size(&mut self, size: f32) {
        if self.font_size == size {
            return;
        }
        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 {
            return;
        }
        self.family = family;
        self.layout = None;
        self.dirt.mark_layout();
    }

    /// Build (or reuse) the single-line label layout.
    fn layout_at(&mut self, text_cx: &mut TextContext) -> &Layout<Brush> {
        if self.layout.is_none() {
            let mut builder =
                text_cx
                    .layout_cx
                    .ranged_builder(&mut text_cx.font_cx, &self.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(&self.label);
            // Buttons don't wrap; the label takes its natural width.
            layout.break_all_lines(None);
            layout.align(Alignment::Start, AlignmentOptions::default());
            self.layout = Some(layout);
        }
        self.layout.as_ref().expect("just built")
    }
}

impl Widget for Button {
    fn measure(
        &mut self,
        text_cx: &mut TextContext,
        _known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        let layout = self.layout_at(text_cx);
        taffy::Size {
            width: (layout.width() + (PAD_X * 2.0) as f32).ceil(),
            height: (layout.height() + (PAD_Y * 2.0) as f32).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 bounds = Rect::from_origin_size((0.0, 0.0), size);
        fragment.fill(
            bounds.to_rounded_rect(t.corner_radius),
            t.background.clone(),
        );
        if !super::is_transparent(&t.focus_ring_color) {
            // Focus ring: a stroke just inside the edge. Its visibility is
            // the theme's decision — the `:focus` rule makes the color
            // opaque — so the widget stays interaction-state-agnostic.
            let inset = bounds.inset(-t.focus_ring_width / 2.0 - 1.0);
            fragment.stroke(
                inset.to_rounded_rect((t.corner_radius - 1.0).max(0.0)),
                t.focus_ring_color.clone(),
                Stroke::new(t.focus_ring_width),
            );
        }
        if let Some(layout) = &self.layout {
            let origin = Point::new(
                (size.width - layout.width() as f64) / 2.0,
                (size.height - layout.height() as f64) / 2.0,
            );
            crate::text::append_layout(fragment, layout, origin);
        }
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::Button
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        node.set_label(self.label.clone());
    }

    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 {
        "button"
    }

    fn focusable(&self) -> bool {
        true
    }

    /// A click on the button is the button's; it does not also fire an
    /// enclosing container's `:on-click`.
    fn consumes_click(&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::Enter => {}
            Key::Character(text) if text == " " => {}
            _ => return false,
        }
        self.emitted.push(EventData::Activated);
        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; rebuild 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;