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;