//! 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, layout: Option>, tokens: Tokens, dirt: Dirt, emitted: Vec, } 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) -> 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) -> Self { self.set_family(family); self } pub fn label(&self) -> &str { &self.label } pub fn set_label(&mut self, label: impl Into) { 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) { 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 { 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>, _available: taffy::Size, ) -> taffy::Size { 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 { 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;