mod core;
pub mod gradient;
pub(crate) mod motion;
mod overflow;
use std::rc::Rc;
use frust::authoring::{AnyView, BuildCtx, ChangeFlags, ChildPod, View};
use kurbo::Point;
use crate::interaction::{HapticSignal, InteractionState};
use self::core::LabelRun;
pub use self::core::{
ButtonDecoration, ButtonSurface, ContentMetrics, DecorationOutcome, OverflowObserver,
};
pub use self::gradient::{
GradientAlignment, GradientButtonDecoration, GradientProperty, GradientSpec, GradientStates,
LinearGradientSpec, RadialGradientSpec, SweepGradientSpec, constant_gradient, implied_stops,
};
use self::motion::RadiusPaddingMotion;
pub use self::overflow::OverflowStrategy;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonVariant {
#[default]
Filled,
Outlined,
Tonal,
Elevated,
Text,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonSize {
Xs,
#[default]
Sm,
Md,
Lg,
Xl,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonShape {
#[default]
Round,
Square,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IconAlignment {
#[default]
Start,
End,
}
type OnPress<State> = Rc<dyn Fn(&mut State)>;
pub struct ButtonView<State: 'static> {
label: String,
variant: ButtonVariant,
size: ButtonSize,
shape: ButtonShape,
enabled: bool,
icon: Option<AnyView<State>>,
icon_alignment: IconAlignment,
corner_radius: Option<f64>,
pressed_radius: Option<f64>,
haptic: HapticSignal,
decoration: Option<Rc<dyn ButtonDecoration>>,
overflow: Option<Rc<dyn OverflowObserver>>,
overflow_strategy: OverflowStrategy,
on_press: OnPress<State>,
}
pub fn button<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
ButtonView {
label: label.into(),
variant: ButtonVariant::Filled,
size: ButtonSize::default(),
shape: ButtonShape::default(),
enabled: true,
icon: None,
icon_alignment: IconAlignment::default(),
corner_radius: None,
pressed_radius: None,
haptic: HapticSignal::None,
decoration: None,
overflow: None,
overflow_strategy: OverflowStrategy::default(),
on_press: Rc::new(on_press),
}
}
#[allow(non_snake_case)]
pub fn Button<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
button(label, on_press)
}
pub fn filled_button<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
button(label, on_press)
}
pub fn outlined_button<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
button(label, on_press).variant(ButtonVariant::Outlined)
}
pub fn tonal_button<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
button(label, on_press).variant(ButtonVariant::Tonal)
}
pub fn elevated_button<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
button(label, on_press).variant(ButtonVariant::Elevated)
}
pub fn text_button<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
button(label, on_press).variant(ButtonVariant::Text)
}
pub fn button_with_icon<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
button(label, on_press).icon(icon)
}
impl<State: 'static> ButtonView<State> {
pub fn variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn size(mut self, size: ButtonSize) -> Self {
self.size = size;
self
}
pub fn shape(mut self, shape: ButtonShape) -> Self {
self.shape = shape;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn icon(mut self, icon: AnyView<State>) -> Self {
self.icon = Some(icon);
self
}
pub fn icon_alignment(mut self, alignment: IconAlignment) -> Self {
self.icon_alignment = alignment;
self
}
pub fn corner_radius(mut self, radius: f64) -> Self {
self.corner_radius = Some(radius);
self
}
pub fn pressed_radius(mut self, radius: f64) -> Self {
self.pressed_radius = Some(radius);
self
}
pub fn haptic(mut self, haptic: HapticSignal) -> Self {
self.haptic = haptic;
self
}
pub fn decoration(mut self, decoration: Rc<dyn ButtonDecoration>) -> Self {
self.decoration = Some(decoration);
self
}
pub fn overflow_observer(mut self, observer: Rc<dyn OverflowObserver>) -> Self {
self.overflow = Some(observer);
self
}
pub fn overflow(mut self, strategy: OverflowStrategy) -> Self {
self.overflow_strategy = strategy;
self
}
}
pub struct ButtonWidget {
pub(super) variant: ButtonVariant,
pub(super) size: ButtonSize,
pub(super) shape: ButtonShape,
pub(super) enabled: bool,
label: LabelRun,
label_origin: Point,
icon: Option<ChildPod>,
icon_alignment: IconAlignment,
corner_radius: Option<f64>,
pressed_radius: Option<f64>,
haptic: HapticSignal,
state: InteractionState,
captured: bool,
motion: RadiusPaddingMotion,
metrics: ContentMetrics,
decoration: Option<Rc<dyn ButtonDecoration>>,
overflow: Option<Rc<dyn OverflowObserver>>,
pub(super) overflow_strategy: OverflowStrategy,
pub(super) scroll_offset: f64,
pub(super) drag_last_x: Option<f64>,
on_press: frust::authoring::ErasedCallback,
}
impl<State: 'static> View<State> for ButtonView<State> {
type Element = ButtonWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonWidget {
let icon = self
.icon
.as_ref()
.map(|icon| frust::authoring::build_child(icon, ctx));
ButtonWidget {
variant: self.variant,
size: self.size,
shape: self.shape,
enabled: self.enabled,
label: LabelRun::new(self.label.clone()),
label_origin: Point::ZERO,
icon,
icon_alignment: self.icon_alignment,
corner_radius: self.corner_radius,
pressed_radius: self.pressed_radius,
haptic: self.haptic,
state: InteractionState::new(),
captured: false,
motion: RadiusPaddingMotion::new(),
metrics: ContentMetrics::empty(),
decoration: self.decoration.clone(),
overflow: self.overflow.clone(),
overflow_strategy: self.overflow_strategy,
scroll_offset: 0.0,
drag_last_x: None,
on_press: frust::authoring::erase_callback(&self.on_press),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut ButtonWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_press = frust::authoring::erase_callback(&self.on_press);
let mut flags = ChangeFlags::NONE;
let decoration_changed = match (&prev.decoration, &self.decoration) {
(Some(a), Some(b)) => !Rc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
if decoration_changed {
flags |= ChangeFlags::PAINT;
}
element.decoration = self.decoration.clone();
let overflow_changed = match (&prev.overflow, &self.overflow) {
(Some(a), Some(b)) => !Rc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
if overflow_changed {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
element.overflow = self.overflow.clone();
element.haptic = self.haptic;
if prev.overflow_strategy != self.overflow_strategy {
element.overflow_strategy = self.overflow_strategy;
element.scroll_offset = 0.0;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.label != self.label {
element.label.set_content(&self.label);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.variant != self.variant {
element.variant = self.variant;
flags |= ChangeFlags::PAINT;
}
if prev.size != self.size {
element.size = self.size;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.shape != self.shape {
element.shape = self.shape;
flags |= ChangeFlags::PAINT;
}
if prev.enabled != self.enabled {
element.enabled = self.enabled;
if !self.enabled {
element.state.set_pressed(false);
element.captured = false;
}
flags |= ChangeFlags::PAINT;
}
if prev.icon_alignment != self.icon_alignment {
element.icon_alignment = self.icon_alignment;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.corner_radius != self.corner_radius {
element.corner_radius = self.corner_radius;
flags |= ChangeFlags::PAINT;
}
if prev.pressed_radius != self.pressed_radius {
element.pressed_radius = self.pressed_radius;
flags |= ChangeFlags::PAINT;
}
match (&prev.icon, &self.icon) {
(None, None) => {}
(Some(p), Some(n)) => {
let pod = element.icon.as_mut().expect("icon pod present");
flags |= frust::authoring::rebuild_child(p, n, pod, ctx);
}
(None, Some(n)) => {
element.icon = Some(frust::authoring::build_child(n, ctx));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(Some(p), None) => {
let mut pod = element.icon.take().expect("icon pod present");
frust::authoring::teardown_child(p, &mut pod, ctx);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
flags
}
fn teardown(&self, element: &mut ButtonWidget, ctx: &mut BuildCtx<'_>) {
if let (Some(icon_view), Some(pod)) = (&self.icon, element.icon.as_mut()) {
frust::authoring::teardown_child(icon_view, pod, ctx);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust_widgets::test_support::leaf_any;
fn build<S: 'static>(view: &ButtonView<S>) -> ButtonWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
#[test]
fn the_named_constructors_map_onto_the_reference_styles() {
assert_eq!(
button::<u32, _>("a", |_| {}).variant,
ButtonVariant::Filled,
"the default constructor is filled"
);
assert_eq!(
filled_button::<u32, _>("a", |_| {}).variant,
ButtonVariant::Filled
);
assert_eq!(
outlined_button::<u32, _>("a", |_| {}).variant,
ButtonVariant::Outlined
);
assert_eq!(
tonal_button::<u32, _>("a", |_| {}).variant,
ButtonVariant::Tonal
);
assert_eq!(
elevated_button::<u32, _>("a", |_| {}).variant,
ButtonVariant::Elevated
);
assert_eq!(
text_button::<u32, _>("a", |_| {}).variant,
ButtonVariant::Text
);
}
#[test]
fn defaults_match_the_reference_constructor_defaults() {
let view = button::<u32, _>("Save", |_| {});
assert_eq!(view.size, ButtonSize::Sm);
assert_eq!(view.shape, ButtonShape::Round);
assert!(view.enabled);
assert_eq!(view.haptic, HapticSignal::None);
assert_eq!(view.icon_alignment, IconAlignment::Start);
assert!(view.icon.is_none());
assert_eq!(view.overflow_strategy, OverflowStrategy::None);
}
#[test]
fn the_icon_factory_and_the_builder_method_agree() {
let factory = button_with_icon::<(), _>(leaf_any(20.0, 20.0), "Save", |_| {});
let builder = button::<(), _>("Save", |_| {}).icon(leaf_any(20.0, 20.0));
assert!(factory.icon.is_some());
assert!(builder.icon.is_some());
assert_eq!(factory.label, builder.label);
assert_eq!(factory.variant, builder.variant);
}
#[test]
fn build_carries_every_prop_onto_the_widget() {
let view = button::<(), _>("Save", |_| {})
.variant(ButtonVariant::Tonal)
.size(ButtonSize::Lg)
.shape(ButtonShape::Square)
.enabled(false)
.haptic(HapticSignal::Light)
.icon(leaf_any(32.0, 32.0))
.icon_alignment(IconAlignment::End)
.corner_radius(9.0)
.pressed_radius(3.0)
.overflow(OverflowStrategy::Scroll);
let widget = build(&view);
assert_eq!(widget.variant, ButtonVariant::Tonal);
assert_eq!(widget.size, ButtonSize::Lg);
assert_eq!(widget.shape, ButtonShape::Square);
assert!(!widget.enabled);
assert_eq!(widget.haptic, HapticSignal::Light);
assert_eq!(widget.icon_alignment, IconAlignment::End);
assert_eq!(widget.corner_radius, Some(9.0));
assert_eq!(widget.pressed_radius, Some(3.0));
assert!(widget.icon.is_some());
assert_eq!(widget.overflow_strategy, OverflowStrategy::Scroll);
}
#[test]
fn rebuild_carries_a_changed_overflow_strategy_and_resets_the_scroll_offset() {
let prev = button::<u32, _>("Save", |_| {});
let next = button::<u32, _>("Save", |_| {}).overflow(OverflowStrategy::BottomSheet);
let mut widget = build(&prev);
widget.scroll_offset = 12.0;
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert_eq!(widget.overflow_strategy, OverflowStrategy::BottomSheet);
assert_eq!(widget.scroll_offset, 0.0);
assert!(flags.contains(ChangeFlags::LAYOUT));
assert!(flags.contains(ChangeFlags::PAINT));
}
#[test]
fn rebuild_moves_props_and_flags_what_changed() {
let prev = button::<u32, _>("Save", |_| {});
let next = button::<u32, _>("Saved", |_| {})
.variant(ButtonVariant::Text)
.size(ButtonSize::Xl);
let mut widget = build(&prev);
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert_eq!(widget.variant, ButtonVariant::Text);
assert_eq!(widget.size, ButtonSize::Xl);
assert_eq!(
widget.label.content(),
"Saved",
"the run takes the new text (and drops its cached shaping)"
);
assert!(flags.contains(ChangeFlags::LAYOUT), "text + size relayout");
assert!(flags.contains(ChangeFlags::PAINT));
}
#[test]
fn disabling_mid_press_clears_the_press_and_the_capture() {
let prev = button::<u32, _>("Save", |_| {});
let next = button::<u32, _>("Save", |_| {}).enabled(false);
let mut widget = build(&prev);
widget.state.set_pressed(true);
widget.captured = true;
let mut counter = 0u64;
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(!widget.state.pressed);
assert!(!widget.captured);
}
struct NoopDecoration;
impl ButtonDecoration for NoopDecoration {}
struct NoopObserver;
impl OverflowObserver for NoopObserver {
fn measured(&self, _metrics: ContentMetrics) {}
}
#[test]
fn rebuild_flags_paint_on_a_decoration_swap_even_though_both_sides_are_some() {
let deco_a: Rc<dyn ButtonDecoration> = Rc::new(NoopDecoration);
let deco_b: Rc<dyn ButtonDecoration> = Rc::new(NoopDecoration);
let prev = button::<u32, _>("Save", |_| {}).decoration(deco_a);
let next = button::<u32, _>("Save", |_| {}).decoration(deco_b);
let mut widget = build(&prev);
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(
flags.contains(ChangeFlags::PAINT),
"a decoration swap (a distinct Rc on each side) must repaint even \
though the Option stays Some on both sides"
);
}
#[test]
fn rebuild_flags_no_paint_when_the_same_rc_decoration_is_reinstalled() {
let deco: Rc<dyn ButtonDecoration> = Rc::new(NoopDecoration);
let prev = button::<u32, _>("Save", |_| {}).decoration(deco.clone());
let next = button::<u32, _>("Save", |_| {}).decoration(deco);
let mut widget = build(&prev);
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(
!flags.contains(ChangeFlags::PAINT),
"reinstalling the identical Rc must not repaint every decorated \
button on every unrelated whole-app rebuild"
);
}
#[test]
fn rebuild_flags_paint_when_a_decoration_is_installed_or_removed() {
let deco: Rc<dyn ButtonDecoration> = Rc::new(NoopDecoration);
let none = button::<u32, _>("Save", |_| {});
let some = button::<u32, _>("Save", |_| {}).decoration(deco);
let mut widget = build(&none);
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&some, &none, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(
flags.contains(ChangeFlags::PAINT),
"installing a decoration must repaint"
);
let mut widget = build(&some);
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&none, &some, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(
flags.contains(ChangeFlags::PAINT),
"removing a decoration must repaint"
);
}
#[test]
fn rebuild_flags_layout_on_an_overflow_observer_swap() {
let obs_a: Rc<dyn OverflowObserver> = Rc::new(NoopObserver);
let obs_b: Rc<dyn OverflowObserver> = Rc::new(NoopObserver);
let prev = button::<u32, _>("Save", |_| {}).overflow_observer(obs_a);
let next = button::<u32, _>("Save", |_| {}).overflow_observer(obs_b);
let mut widget = build(&prev);
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(
flags.contains(ChangeFlags::LAYOUT),
"an overflow-observer swap (a distinct Rc on each side) is a \
layout-time seam (`measured`/event), not a paint-time one, so \
the swap must flag LAYOUT even though the Option stays Some"
);
}
#[test]
fn rebuild_flags_no_layout_when_the_same_rc_overflow_observer_is_reinstalled() {
let obs: Rc<dyn OverflowObserver> = Rc::new(NoopObserver);
let prev = button::<u32, _>("Save", |_| {}).overflow_observer(obs.clone());
let next = button::<u32, _>("Save", |_| {}).overflow_observer(obs);
let mut widget = build(&prev);
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert!(
!flags.contains(ChangeFlags::LAYOUT),
"reinstalling the identical Rc must not relayout"
);
}
#[test]
fn rebuild_adopts_a_haptic_change_with_no_invalidation() {
let prev = button::<u32, _>("Save", |_| {});
let next = button::<u32, _>("Save", |_| {}).haptic(HapticSignal::Light);
let mut widget = build(&prev);
let mut counter = 0u64;
let flags =
View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
assert_eq!(
flags,
ChangeFlags::NONE,
"a haptic-only change needs no invalidation"
);
assert_eq!(
widget.haptic,
HapticSignal::Light,
"the value is still adopted despite flagging nothing"
);
}
}