Add Properties to Button (#892)

Depends on #904.

Add BackgroundColor, BorderColor, BorderWidth, CornerRadius and Padding
properties.
Modularize button painting code.
Paint border after background to get slightly better border appearance.
Update screenshots.
Add screenshot test using properties.
This commit is contained in:
Olivier FAURE 2025-03-29 10:43:22 +01:00 committed by GitHub
parent 5c32f5c44b
commit fad997afa5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 315 additions and 86 deletions

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:102ab52259d07eb250f83acaeb5a985549ef2b5052eae7fca8e882548b34955a
size 17856
oid sha256:4fb1e0aad3dc074028a723304a04af836d4496a547bc6377acd4f0ea4caeaacd
size 17713

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8c74fb27ee2165e855dd631e496426793344c93dee37cdb9bb989ba3129cf495
size 6758
oid sha256:6c8c66f1c91c102ef9f3b806f8f380eec781d4eb2261f421a651829c49f2686e
size 1542

View File

@ -0,0 +1,23 @@
// Copyright 2025 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use std::any::TypeId;
use crate::core::UpdateCtx;
use crate::peniko::color::{AlphaColor, Srgb};
/// The background color of a widget.
#[derive(Clone, Copy, Debug)]
pub struct BackgroundColor {
pub color: AlphaColor<Srgb>,
}
impl BackgroundColor {
/// Helper function to be called in [`Widget::property_changed`](crate::core::Widget::property_changed).
pub fn prop_changed(ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
if property_type != TypeId::of::<Self>() {
return;
}
ctx.request_paint_only();
}
}

View File

@ -0,0 +1,23 @@
// Copyright 2025 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use std::any::TypeId;
use crate::core::UpdateCtx;
use crate::peniko::color::{AlphaColor, Srgb};
/// The background color of a widget.
#[derive(Clone, Copy, Debug)]
pub struct BorderColor {
pub color: AlphaColor<Srgb>,
}
impl BorderColor {
/// Helper function to be called in [`Widget::property_changed`](crate::core::Widget::property_changed).
pub fn prop_changed(ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
if property_type != TypeId::of::<Self>() {
return;
}
ctx.request_paint_only();
}
}

View File

@ -0,0 +1,69 @@
// Copyright 2025 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use std::any::TypeId;
use crate::core::{BoxConstraints, UpdateCtx};
use crate::kurbo::{Point, RoundedRect, Size, Vec2};
use crate::properties::CornerRadius;
/// The width of a widget's border.
#[derive(Clone, Copy, Debug)]
pub struct BorderWidth {
pub width: f64,
}
impl BorderWidth {
/// Helper function to be called in [`Widget::property_changed`](crate::core::Widget::property_changed).
pub fn prop_changed(ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
if property_type != TypeId::of::<Self>() {
return;
}
ctx.request_layout();
}
/// Shrinks the box constraints by the border width.
///
/// Helper function to be called in [`Widget::layout`](crate::core::Widget::layout).
pub fn layout_down(&self, bc: BoxConstraints) -> BoxConstraints {
bc.shrink((self.width * 2., self.width * 2.))
}
/// Expands the size and raises the baseline by the border width.
///
/// Helper function to be called in [`Widget::layout`](crate::core::Widget::layout).
pub fn layout_up(&self, size: Size, baseline: f64) -> (Size, f64) {
let size = Size::new(size.width + self.width * 2., size.height + self.width * 2.);
let baseline = baseline + self.width;
(size, baseline)
}
/// Shifts the position by the border width.
///
/// Helper function to be called in [`Widget::layout`](crate::core::Widget::layout).
pub fn place_down(&self, pos: Point) -> Point {
pos + Vec2::new(self.width, self.width)
}
/// Creates a rounded rectangle that is inset by the border width.
///
/// Use to display a box's background.
///
/// Helper function to be called in [`Widget::paint`](crate::core::Widget::paint).
pub fn bg_rect(&self, size: Size, border_radius: &CornerRadius) -> RoundedRect {
size.to_rect()
.inset(-self.width)
.to_rounded_rect(border_radius.radius - self.width)
}
/// Creates a rounded rectangle that is inset by half the border width.
///
/// Use to display a box's border.
///
/// Helper function to be called in [`Widget::paint`](crate::core::Widget::paint).
pub fn border_rect(&self, size: Size, border_radius: &CornerRadius) -> RoundedRect {
size.to_rect()
.inset(-self.width / 2.0)
.to_rounded_rect(border_radius.radius)
}
}

View File

@ -0,0 +1,21 @@
// Copyright 2025 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use std::any::TypeId;
use crate::core::UpdateCtx;
/// The radius of a widget's box corners.
#[derive(Clone, Copy, Debug)]
pub struct CornerRadius {
pub radius: f64,
}
impl CornerRadius {
pub(crate) fn prop_changed(ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
if property_type != TypeId::of::<Self>() {
return;
}
ctx.request_layout();
}
}

View File

@ -10,24 +10,14 @@
reason = "A lot of properties and especially their fields are self-explanatory."
)]
use std::any::TypeId;
mod background_color;
mod border_color;
mod border_width;
mod corner_radius;
mod padding;
use vello::peniko::color::{AlphaColor, Srgb};
use crate::core::UpdateCtx;
// TODO - Split out into files.
/// The background color of a widget.
#[derive(Clone, Copy, Debug)]
pub struct BackgroundColor {
pub color: AlphaColor<Srgb>,
}
impl BackgroundColor {
pub(crate) fn prop_changed(ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
if property_type == TypeId::of::<Self>() {
ctx.request_paint_only();
}
}
}
pub use background_color::BackgroundColor;
pub use border_color::BorderColor;
pub use border_width::BorderWidth;
pub use corner_radius::CornerRadius;
pub use padding::Padding;

View File

@ -0,0 +1,47 @@
// Copyright 2025 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use std::any::TypeId;
use crate::core::{BoxConstraints, UpdateCtx};
use crate::kurbo::{Point, Size, Vec2};
/// The width of padding between a widget's border and its contents.
#[derive(Clone, Copy, Debug)]
pub struct Padding {
pub x: f64,
pub y: f64,
}
impl Padding {
/// Helper function to be called in [`Widget::property_changed`](crate::core::Widget::property_changed).
pub fn prop_changed(ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
if property_type != TypeId::of::<Self>() {
return;
}
ctx.request_layout();
}
/// Shrinks the box constraints by the padding amount.
///
/// Helper function to be called in [`Widget::layout`](crate::core::Widget::layout).
pub fn layout_down(&self, bc: BoxConstraints) -> BoxConstraints {
bc.shrink((self.x * 2., self.y * 2.))
}
/// Expands the size and raises the baseline by the padding amount.
///
/// Helper function to be called in [`Widget::layout`](crate::core::Widget::layout).
pub fn layout_up(&self, size: Size, baseline: f64) -> (Size, f64) {
let size = Size::new(size.width + self.x * 2., size.height + self.y * 2.);
let baseline = baseline + self.y;
(size, baseline)
}
/// Shifts the position by the padding amount.
///
/// Helper function to be called in [`Widget::layout`](crate::core::Widget::layout).
pub fn place_down(&self, pos: Point) -> Point {
pos + Vec2::new(self.x, self.y)
}
}

View File

@ -3,6 +3,8 @@
//! A button widget.
use std::any::TypeId;
use accesskit::{Node, Role};
use smallvec::{SmallVec, smallvec};
use tracing::{Span, trace, trace_span};
@ -13,15 +15,26 @@ use crate::core::{
PointerButton, PointerEvent, PropertiesMut, PropertiesRef, QueryCtx, TextEvent, Update,
UpdateCtx, Widget, WidgetId, WidgetMut, WidgetPod,
};
use crate::kurbo::{Insets, Size};
use crate::kurbo::Size;
use crate::properties::*;
use crate::theme;
use crate::util::{UnitPoint, fill_lin_gradient, stroke};
use crate::widgets::Label;
// The minimum padding added to a button.
// --- MARK: CONSTANTS ---
const DEFAULT_BORDER_COLOR: BorderColor = BorderColor {
color: theme::BORDER_DARK,
};
const DEFAULT_BORDER_WIDTH: BorderWidth = BorderWidth {
width: theme::BUTTON_BORDER_WIDTH,
};
const DEFAULT_BORDER_RADII: CornerRadius = CornerRadius {
radius: theme::BUTTON_BORDER_RADIUS,
};
// NOTE: these values are chosen to match the existing look of TextBox; these
// should be reevaluated at some point.
const LABEL_INSETS: Insets = Insets::uniform_xy(8., 2.);
const DEFAULT_PADDING: Padding = Padding { x: 8., y: 2. };
/// A button with a text label.
///
@ -151,66 +164,82 @@ impl Widget for Button {
ctx.register_child(&mut self.label);
}
fn property_changed(&mut self, ctx: &mut UpdateCtx, property_type: TypeId) {
BorderColor::prop_changed(ctx, property_type);
BorderWidth::prop_changed(ctx, property_type);
CornerRadius::prop_changed(ctx, property_type);
Padding::prop_changed(ctx, property_type);
}
fn layout(
&mut self,
ctx: &mut LayoutCtx,
_props: &mut PropertiesMut<'_>,
props: &mut PropertiesMut<'_>,
bc: &BoxConstraints,
) -> Size {
let padding = Size::new(LABEL_INSETS.x_value(), LABEL_INSETS.y_value());
let label_bc = bc.shrink(padding).loosen();
let border = props.get::<BorderWidth>().unwrap_or(&DEFAULT_BORDER_WIDTH);
let padding = props.get::<Padding>().unwrap_or(&DEFAULT_PADDING);
let label_size = ctx.run_layout(&mut self.label, &label_bc);
let initial_bc = bc;
let bc = bc.loosen();
let bc = border.layout_down(bc);
let bc = padding.layout_down(bc);
let label_size = ctx.run_layout(&mut self.label, &bc);
let baseline = ctx.child_baseline_offset(&self.label);
ctx.set_baseline_offset(baseline + LABEL_INSETS.y1);
let size = label_size;
let (size, baseline) = padding.layout_up(size, baseline);
let (size, baseline) = border.layout_up(size, baseline);
// TODO - Add MinimumSize property.
// HACK: to make sure we look okay at default sizes when beside a textbox,
// we make sure we will have at least the same height as the default textbox.
let min_height = theme::BORDERED_WIDGET_HEIGHT;
let mut size = size;
size.height = size.height.max(theme::BORDERED_WIDGET_HEIGHT);
let button_size = bc.constrain(Size::new(
label_size.width + padding.width,
(label_size.height + padding.height).max(min_height),
));
let label_offset = (button_size.to_vec2() - label_size.to_vec2()) / 2.0;
// TODO - Figure out how to handle cases where label size doesn't fit bc.
let size = initial_bc.constrain(size);
let label_offset = (size.to_vec2() - label_size.to_vec2()) / 2.0;
ctx.place_child(&mut self.label, label_offset.to_point());
button_size
// TODO - pos = (size - label_size) / 2
ctx.set_baseline_offset(baseline);
size
}
fn paint(&mut self, ctx: &mut PaintCtx, _props: &PropertiesRef<'_>, scene: &mut Scene) {
let is_active = ctx.is_pointer_capture_target() && !ctx.is_disabled();
fn paint(&mut self, ctx: &mut PaintCtx, props: &PropertiesRef<'_>, scene: &mut Scene) {
let is_pressed = ctx.is_pointer_capture_target() && !ctx.is_disabled();
let is_hovered = ctx.is_hovered();
let size = ctx.size();
let stroke_width = theme::BUTTON_BORDER_WIDTH;
let border_radius = theme::BUTTON_BORDER_RADIUS;
let bg_rect = size
.to_rect()
.inset(-stroke_width)
.to_rounded_rect(border_radius - stroke_width);
let border_rect = size
.to_rect()
.inset(-stroke_width / 2.0)
.to_rounded_rect(border_radius);
let border_color = props.get::<BorderColor>().unwrap_or(&DEFAULT_BORDER_COLOR);
let border_width = props.get::<BorderWidth>().unwrap_or(&DEFAULT_BORDER_WIDTH);
let border_radius = props.get::<CornerRadius>().unwrap_or(&DEFAULT_BORDER_RADII);
let bg_rect = border_width.bg_rect(size, border_radius);
let border_rect = border_width.border_rect(size, border_radius);
// TODO - Handle gradient bg with properties.
let bg_gradient = if ctx.is_disabled() {
[theme::DISABLED_BUTTON_LIGHT, theme::DISABLED_BUTTON_DARK]
} else if is_active {
} else if is_pressed {
[theme::BUTTON_DARK, theme::BUTTON_LIGHT]
} else {
[theme::BUTTON_LIGHT, theme::BUTTON_DARK]
};
// TODO - Handle hovered color with properties.
let border_color = if is_hovered && !ctx.is_disabled() {
theme::BORDER_LIGHT
BorderColor {
color: theme::BORDER_LIGHT,
}
} else {
theme::BORDER_DARK
*border_color
};
stroke(scene, &border_rect, border_color, stroke_width);
fill_lin_gradient(
scene,
&bg_rect,
@ -218,6 +247,7 @@ impl Widget for Button {
UnitPoint::TOP,
UnitPoint::BOTTOM,
);
stroke(scene, &border_rect, border_color.color, border_width.width);
}
fn accessibility_role(&self) -> Role {
@ -309,4 +339,27 @@ mod tests {
// We don't use assert_eq because we don't want rich assert
assert!(image_1 == image_2);
}
#[test]
fn set_properties() {
let red = crate::palette::css::RED;
let button = Button::new("Some random text");
let window_size = Size::new(200.0, 80.0);
let mut harness = TestHarness::create_with_size(button, window_size);
harness.edit_root_widget(|mut button| {
let mut button = button.downcast::<Button>();
button.insert_prop(BorderColor { color: red });
button.insert_prop(BorderWidth { width: 5.0 });
button.insert_prop(CornerRadius { radius: 20.0 });
button.insert_prop(Padding { x: 8.0, y: 3.0 });
let mut label = Button::label_mut(&mut button);
Label::set_brush(&mut label, red);
});
assert_render_snapshot!(harness, "set_properties");
}
}

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:825ba955c58734a2f82f08cbafada08564df81ce7cdba3c91b9f6d9c479739fb
size 826
oid sha256:28c7e13b2243154c8f7c875995f5cb8e494b35b179f16e87bba841df2c075e13
size 850

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49af17e2d7c0c00f14a2e6148fcb17fa00723864fef9a1a669d96b64731152e0
size 2696

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b955377e7a281de6023c89c2e1df74bb022fd79d9b66f61f94935d2745d51f20
size 633
oid sha256:952d44c493901d7d573ed1f52692d9a00f16bdf27b22dec5a5f8be967c46e10c
size 657

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a0cc0452c057a6ad53f6387ddc4dd106d9290007d308caf646d50f207982e31
size 788
oid sha256:fa053bfee46b3c2f06f3ce7279b79a371283bf7d8e0ce4f648edd63ef97a65b1
size 823

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44d955df21b773e49b617a35dd7e947c5fa4cd3f2d03f45669fe45c4bb953b82
size 740
oid sha256:b3ade1c4d892f3d0f46ce5bf318a76dcd76975602ae35a589c66f155d55dc41f
size 749

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8a7a6c3b0d099955329c4507df537c585744cbfef667c70330ff4519cacfd40
size 632
oid sha256:6d40fb5c5f871c7d5e3e4dca8b15d4dde34133aa6a13a18f44b62c02b2642a1b
size 637

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db92d77f0185fa0a093ab0dedf5015ac990891ebc6caea2c08790ad4b53fe26e
size 726
oid sha256:64584c9dbb5c0d8dcbca73e2e0d89447a998f16f740e634e7c0c2151d38dd40a
size 733

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:497972bc7a7b0374104f9bd871bb74a24697439b89d2a099e0c72de358857516
size 662
oid sha256:e9d4d67c617852036aca1192390c82a528cf3a804a02894ccb573c867c2ae99f
size 684

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5107d76df5e0936517691f90ef2249df8d5d50791c899b6bd1c6215830518387
size 673
oid sha256:833caf3788288cd278c7cbd9c9c9976744dbbef08594581814373cf85d7534d2
size 684

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db92d77f0185fa0a093ab0dedf5015ac990891ebc6caea2c08790ad4b53fe26e
size 726
oid sha256:64584c9dbb5c0d8dcbca73e2e0d89447a998f16f740e634e7c0c2151d38dd40a
size 733

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4370d776f4833a0969b6e2759711a40dd29b5ba1de7ab98f909d4277cf39f989
size 1524
oid sha256:61e272612808ccb0d406f4bc311e93de256ad90b12d6429e79dd4110287aa778
size 1550

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c0059bea7b40880476a3dc43c4d1ddd1f865313bb17b39f87f2929c87f4bc13
size 1856
oid sha256:8a0a530636e460d0e488d63dbe2f39d7009ec6a364435ed9126aa430cba0990b
size 1854

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:adebbe105edd9839a1bb534415c54845bdac3c6282516248906988eabc845322
size 904
oid sha256:404e43d6e09799b59802b9d4446521744972a47dc9386d994e2ebfc12d08c758
size 893

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7151dd150c2b41c94049f0d300afc45bc03f2373b9143ea6adf5fa0e47d3067e
size 1624
oid sha256:9d127fed30616831cc500e20de77f26065cb88e4e43bfd1b4aec2140e9fbbda8
size 1630

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b5fc32216fa0af42215981cb6c6e1d893abc6d0514d33417f7d409e265b7a714
size 1300
oid sha256:a3b8226344af72f9b03f280f01384c71da355260096f5cce5b84d38f119eca73
size 1309

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74e774f297202ad08e6db2b44dbe01b6b9f59ad245f67c53d07e523487e351b2
size 11846
oid sha256:f1e34b1be1279938f2bb56ac359270ab67f0930a82a2561c7002d32265361305
size 11912