use std::rc::Rc;
use std::time::Duration;
use frust_core::accesskit::{Action, Role};
use frust_core::{
AnimationController, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Curve, EventCtx,
EventResult, FrameTime, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase,
SemanticsCtx, Tween, View, Widget, any,
};
use frust_theme::Theme;
use kurbo::{Affine, Arc as KurboArc, Point, RoundedRect, Shape, Size, Vec2};
use peniko::{Brush, Color};
use crate::authoring::presses;
use crate::nav::transition::{TransitionDriver, make_driver};
use crate::text;
use crate::text::ThemeTextColor;
use crate::{Alignment, Timing, authoring::PRESSED_OPACITY};
const RADIUS: f64 = 6.0;
const PAD_X: f64 = 12.0;
const PAD_Y: f64 = 8.0;
const SMALL_PAD_X: f64 = 8.0;
const SMALL_PAD_Y: f64 = 4.0;
const FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
const FILL_PRESSED: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
const SECONDARY_FILL: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
const SECONDARY_FILL_PRESSED: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
const SECONDARY_BORDER: Color = Color::from_rgb8(0x9C, 0xA3, 0xAF);
const GHOST_PRESSED_INK: Color = Color::from_rgb8(0x11, 0x18, 0x27);
const DANGER_BORDER: Color = Color::from_rgb8(0xDC, 0x26, 0x26);
const DANGER_PRESSED_WASH: Color = Color::from_rgb8(0xFE, 0xE2, 0xE2);
const UNTHEMED_INK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
const BORDER_WIDTH: f64 = 1.0;
const BORDER_TOLERANCE: f64 = 0.1;
const PRESSED_DARKEN: f32 = 0.82;
const DISABLED_ALPHA: f32 = 0.38;
const PRESSED_SCALE: f64 = 0.96;
const REST_SCALE: f64 = 1.0;
const FALLBACK_PRESS_DURATION: Duration = Duration::from_millis(100);
const SPINNER_RADIUS_RATIO: f64 = 0.4;
const SPINNER_MIN_RADIUS: f64 = 5.0;
const SPINNER_STROKE_WIDTH: f64 = 2.0;
const SPINNER_SWEEP: f64 = std::f64::consts::PI * 1.5;
const SPINNER_PERIOD_MS: u64 = 900;
fn pressed_overlay(color: Color, factor: f32) -> Color {
let c = color.components;
Color::new([c[0] * factor, c[1] * factor, c[2] * factor, c[3]])
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn disabled_alpha(color: Color) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], c[3] * DISABLED_ALPHA])
}
fn scale_about(pivot: Point, scale: f64) -> Affine {
Affine::translate((pivot.x, pivot.y))
* Affine::scale(scale)
* Affine::translate((-pivot.x, -pivot.y))
}
type OnPress<State> = Rc<dyn Fn(&mut State)>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonStyle {
#[default]
Primary,
Secondary,
Ghost,
Danger,
Icon,
}
impl ButtonStyle {
fn label_role(self) -> ThemeTextColor {
match self {
ButtonStyle::Primary => ThemeTextColor::OnPrimary,
ButtonStyle::Secondary | ButtonStyle::Icon => ThemeTextColor::OnSurface,
ButtonStyle::Ghost => ThemeTextColor::OnSurfaceVariant,
ButtonStyle::Danger => ThemeTextColor::Error,
}
}
fn resolve_ink(self, theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => {
let scheme = theme.scheme();
match self {
ButtonStyle::Primary => scheme.on_primary,
ButtonStyle::Secondary | ButtonStyle::Icon => scheme.on_surface,
ButtonStyle::Ghost => scheme.on_surface_variant,
ButtonStyle::Danger => scheme.error,
}
}
None => UNTHEMED_INK,
}
}
fn resolve(self, theme: Option<&Theme>) -> StylePaint {
match theme {
Some(theme) => {
let scheme = theme.scheme();
match self {
ButtonStyle::Primary => StylePaint {
fill: scheme.primary,
fill_pressed: pressed_overlay(scheme.primary, PRESSED_DARKEN),
border: None,
},
ButtonStyle::Secondary | ButtonStyle::Icon => StylePaint {
fill: scheme.surface_container_high,
fill_pressed: pressed_overlay(
scheme.surface_container_high,
PRESSED_DARKEN,
),
border: Some(scheme.outline),
},
ButtonStyle::Ghost => StylePaint {
fill: Color::TRANSPARENT,
fill_pressed: with_alpha(scheme.on_surface, PRESSED_OPACITY),
border: None,
},
ButtonStyle::Danger => StylePaint {
fill: Color::TRANSPARENT,
fill_pressed: scheme.error_container,
border: Some(scheme.error),
},
}
}
None => match self {
ButtonStyle::Primary => StylePaint {
fill: FILL,
fill_pressed: FILL_PRESSED,
border: None,
},
ButtonStyle::Secondary | ButtonStyle::Icon => StylePaint {
fill: SECONDARY_FILL,
fill_pressed: SECONDARY_FILL_PRESSED,
border: Some(SECONDARY_BORDER),
},
ButtonStyle::Ghost => StylePaint {
fill: Color::TRANSPARENT,
fill_pressed: with_alpha(GHOST_PRESSED_INK, PRESSED_OPACITY),
border: None,
},
ButtonStyle::Danger => StylePaint {
fill: Color::TRANSPARENT,
fill_pressed: DANGER_PRESSED_WASH,
border: Some(DANGER_BORDER),
},
},
}
}
}
struct StylePaint {
fill: Color,
fill_pressed: Color,
border: Option<Color>,
}
fn resolve_press_timing(theme: Option<&Theme>, pressed: bool) -> Timing {
match theme {
Some(theme) => {
if pressed {
let instant_ms = theme.motion.durations.instant.max(0.0);
Timing::Duration(
Duration::from_secs_f64(instant_ms / 1000.0),
theme.motion.easing.exit,
)
} else {
Timing::Spring(theme.motion.default_spatial)
}
}
None => {
let curve = if pressed {
Curve::EaseIn
} else {
Curve::EaseOut
};
Timing::Duration(FALLBACK_PRESS_DURATION, curve)
}
}
}
struct PressAnim {
target: f64,
driving_target: f64,
tween: Tween<f64>,
driver: TransitionDriver,
}
impl PressAnim {
fn new() -> Self {
let (driver, _) = make_driver(Timing::Duration(Duration::ZERO, Curve::Linear));
Self {
target: REST_SCALE,
driving_target: REST_SCALE,
tween: Tween::new(REST_SCALE, REST_SCALE),
driver,
}
}
fn value(&self) -> f64 {
self.tween.lerp(self.driver.value())
}
fn set_pressed(&mut self, pressed: bool) {
self.target = if pressed { PRESSED_SCALE } else { REST_SCALE };
}
fn advance(&mut self, now: FrameTime, timing: Timing) -> bool {
if self.target != self.driving_target {
let from = self.value();
self.tween = Tween::new(from, self.target);
self.driving_target = self.target;
let (driver, _) = make_driver(timing);
self.driver = driver;
}
self.driver.advance(now).animating
}
}
fn label_view<State: 'static>(label: String, style: ButtonStyle) -> frust_core::AnyView<State> {
any::<State, _>(text(label).themed_role(style.label_role()))
}
pub struct ButtonView<State: 'static> {
label: String,
on_press: OnPress<State>,
style: ButtonStyle,
small: bool,
loading: bool,
disabled: bool,
label_alignment: Option<Alignment>,
}
pub fn button<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> ButtonView<State> {
ButtonView {
label: label.into(),
on_press: Rc::new(on_press),
style: ButtonStyle::default(),
small: false,
loading: false,
disabled: false,
label_alignment: None,
}
}
#[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)
}
impl<State: 'static> ButtonView<State> {
pub fn style(mut self, style: ButtonStyle) -> Self {
self.style = style;
self
}
pub fn small(mut self) -> Self {
self.small = true;
self
}
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn label_alignment(mut self, alignment: Alignment) -> Self {
self.label_alignment = Some(alignment);
self
}
}
pub struct ButtonWidget {
label: ChildPod,
label_text: String,
pressed: bool,
captured: bool,
on_press: crate::authoring::ErasedCallback,
style: ButtonStyle,
small: bool,
loading: bool,
disabled: bool,
label_alignment: Option<Alignment>,
press: PressAnim,
spinner: AnimationController,
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
impl ButtonWidget {
fn resolve_radius(theme: Option<&Theme>, size: Size) -> f64 {
match theme {
Some(theme) => {
frust_theme::ShapeScale::resolve(theme.shape.small, size.width, size.height)
}
None => RADIUS,
}
}
fn resolve_label_origin(
alignment: Option<Alignment>,
pad_x: f64,
pad_y: f64,
final_size: Size,
content_size: Size,
) -> Point {
let free_x = (final_size.width - content_size.width).max(0.0);
let free_y = (final_size.height - content_size.height).max(0.0);
let effective = alignment
.unwrap_or_else(|| Alignment::new(if free_x > 0.0 { 0.0 } else { -1.0 }, -1.0));
let frac = |component: f64| (component + 1.0) / 2.0;
Point::new(
pad_x + free_x * frac(effective.x),
pad_y + free_y * frac(effective.y),
)
}
fn paint_spinner(&self, ctx: &PaintCtx, scene: &mut dyn PaintScene, color: Color) {
let size = ctx.size();
let center_local = Point::new(size.width / 2.0, size.height / 2.0);
let radius = (size.height / 2.0 * SPINNER_RADIUS_RATIO).max(SPINNER_MIN_RADIUS);
let angle = self.spinner.value() * std::f64::consts::TAU;
let arc = KurboArc::new(
center_local,
Vec2::new(radius, radius),
angle,
SPINNER_SWEEP,
0.0,
);
let path = arc.to_path(0.1);
scene.stroke_path(
ctx.origin(),
&path,
SPINNER_STROKE_WIDTH,
&Brush::Solid(color),
);
}
}
impl<State: 'static> View<State> for ButtonView<State> {
type Element = ButtonWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonWidget {
let label_view = label_view::<State>(self.label.clone(), self.style);
let mut spinner = AnimationController::new(Duration::from_millis(SPINNER_PERIOD_MS))
.with_curve(Curve::Linear);
spinner.repeat();
ButtonWidget {
label: crate::authoring::build_child(&label_view, ctx),
label_text: self.label.clone(),
pressed: false,
captured: false,
on_press: crate::authoring::erase_callback(&self.on_press),
style: self.style,
small: self.small,
loading: self.loading,
disabled: self.disabled,
label_alignment: self.label_alignment,
press: PressAnim::new(),
spinner,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut ButtonWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_press = crate::authoring::erase_callback(&self.on_press);
let mut flags = ChangeFlags::NONE;
if prev.label != self.label || prev.style != self.style {
element.label_text = self.label.clone();
let prev_view = label_view::<State>(prev.label.clone(), prev.style);
let next_view = label_view::<State>(self.label.clone(), self.style);
flags |=
crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
}
if prev.style != self.style {
element.style = self.style;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.small != self.small {
element.small = self.small;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.label_alignment != self.label_alignment {
element.label_alignment = self.label_alignment;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.loading != self.loading {
element.loading = self.loading;
flags |= ChangeFlags::PAINT;
if self.loading {
element.pressed = false;
element.captured = false;
element.press.set_pressed(false);
}
}
if prev.disabled != self.disabled {
element.disabled = self.disabled;
flags |= ChangeFlags::PAINT;
if self.disabled {
element.pressed = false;
element.captured = false;
element.press.set_pressed(false);
}
}
flags
}
fn teardown(&self, element: &mut ButtonWidget, ctx: &mut BuildCtx<'_>) {
let label_view = label_view::<State>(self.label.clone(), self.style);
crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
}
}
impl Widget for ButtonWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let (pad_x, pad_y) = if self.small {
(SMALL_PAD_X, SMALL_PAD_Y)
} else {
(PAD_X, PAD_Y)
};
let inset = Size::new(pad_x * 2.0, pad_y * 2.0);
let inner_max = Size::new(
(bc.max().width - inset.width).max(0.0),
(bc.max().height - inset.height).max(0.0),
);
let label_size = self
.label
.layout_child(ctx, &BoxConstraints::loose(inner_max));
let mut size = Size::new(
label_size.width + inset.width,
label_size.height + inset.height,
);
let content_size = size;
if self.style == ButtonStyle::Icon {
let side = size.width.max(size.height);
size = Size::new(side, side);
self.label.set_origin(Point::new(
(side - label_size.width) / 2.0,
(side - label_size.height) / 2.0,
));
} else {
let final_size = bc.constrain(size);
self.label.set_origin(Self::resolve_label_origin(
self.label_alignment,
pad_x,
pad_y,
final_size,
content_size,
));
}
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let mut paint = self.style.resolve(theme);
let radius = Self::resolve_radius(theme, ctx.size());
let press_timing = resolve_press_timing(theme, self.pressed);
let mut ink = self.style.resolve_ink(theme);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
if self.disabled {
paint.fill = disabled_alpha(paint.fill);
paint.fill_pressed = disabled_alpha(paint.fill_pressed);
if let Some(ref mut border) = paint.border {
*border = disabled_alpha(*border);
}
ink = disabled_alpha(ink);
}
let fill = if self.pressed {
paint.fill_pressed
} else {
paint.fill
};
if self.press.advance(ctx.frame_time(), press_timing) {
ctx.request_frame();
}
let scale = self.press.value();
let origin = ctx.origin();
let size = ctx.size();
let pivot = Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0);
scene.push_transform(scale_about(pivot, scale));
if fill != Color::TRANSPARENT {
scene.fill_rounded_rect(origin, size, radius, fill);
}
if let Some(border) = paint.border {
let half = BORDER_WIDTH / 2.0;
let rr = RoundedRect::new(
half,
half,
size.width - half,
size.height - half,
(radius - half).max(0.0),
);
let path = rr.to_path(BORDER_TOLERANCE);
scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(border));
}
if self.loading {
if !reduce_motion {
self.spinner.advance(ctx.frame_time());
ctx.request_frame_paced();
}
self.paint_spinner(ctx, scene, ink);
} else {
self.label.paint_child(ctx, scene);
}
scene.pop_transform();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if self.loading || self.disabled {
return EventResult::Ignored;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.pressed = true;
self.captured = true;
self.press.set_pressed(true);
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
if !self.captured {
return EventResult::Ignored;
}
self.pressed = inside(p.position, ctx.size());
self.press.set_pressed(self.pressed);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
if inside(p.position, ctx.size()) {
(self.on_press)(ctx);
}
self.pressed = false;
self.captured = false;
self.press.set_pressed(false);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.pressed = false;
self.captured = false;
self.press.set_pressed(false);
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::Button, |node| {
node.set_label(self.label_text.as_str());
if self.loading || self.disabled {
node.set_disabled();
} else {
node.add_action(Action::Click);
}
});
}
crate::authoring::visit_children!(label);
}
#[cfg(test)]
mod tests {
use super::*;
use std::any::Any;
#[derive(Default)]
struct Counter {
presses: u32,
}
fn widget() -> ButtonWidget {
let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1);
let mut counter = 0u64;
View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust_core::PointerEvent {
phase,
position: Point::new(x, y),
button: frust_core::PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust_core::PointerEvent {
phase,
position: Point::new(x, y),
button: frust_core::PointerButton::Secondary,
})
}
fn dispatch(w: &mut ButtonWidget, state: &mut Counter, event: &InputEvent) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
w.event(&mut ctx, event);
}
#[test]
fn a_secondary_press_neither_presses_nor_captures_nor_fires() {
let mut w = widget();
let mut state = Counter::default();
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Down, 10.0, 10.0),
);
assert!(!w.pressed, "no pressed chrome on a right-click");
assert!(!w.captured, "and no capture for the shell to wedge on");
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Up, 10.0, 10.0),
);
assert_eq!(state.presses, 0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
assert!(w.pressed);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(state.presses, 1);
}
#[test]
fn down_then_up_inside_fires_once() {
let mut w = widget();
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
assert!(w.pressed);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
assert_eq!(state.presses, 1);
assert!(!w.pressed);
}
#[test]
fn down_inside_move_out_up_outside_does_not_fire() {
let mut w = widget();
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
assert!(!w.pressed, "moving out clears the pressed visual");
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 10.0));
assert_eq!(state.presses, 0, "up outside must not fire");
}
#[test]
fn cancel_clears_pressed_without_firing() {
let mut w = widget();
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
assert!(!w.pressed);
assert_eq!(state.presses, 0);
}
#[test]
fn hover_move_without_down_is_ignored_noop() {
let mut w = widget();
let mut state = Counter::default();
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 20.0, 20.0));
assert!(matches!(result, EventResult::Ignored));
assert!(!w.pressed, "hover must not press");
assert!(!ctx.needs_redraw(), "hover must not request a redraw");
assert_eq!(state.presses, 0);
}
#[test]
fn up_without_down_does_not_fire() {
let mut w = widget();
let mut state = Counter::default();
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
let result = w.event(&mut ctx, &ev(PointerPhase::Up, 20.0, 20.0));
assert!(matches!(result, EventResult::Ignored));
assert_eq!(state.presses, 0, "an unarmed Up must never fire");
}
#[test]
fn cancel_clears_armed_state() {
let mut w = widget();
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
assert!(w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
assert!(!w.captured, "Cancel disarms the press");
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 12.0, 12.0));
assert!(matches!(result, EventResult::Ignored));
assert!(!w.pressed);
}
#[derive(Default)]
struct RRectRecorder {
rrects: Vec<(f64, Color)>,
strokes: Vec<(f64, Color)>,
transforms: Vec<Affine>,
transform_pops: u32,
}
impl PaintScene for RRectRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, radius: f64, color: Color) {
self.rrects.push((radius, color));
}
fn stroke_path(
&mut self,
_origin: Point,
_path: &kurbo::BezPath,
width: f64,
brush: &Brush,
) {
if let Brush::Solid(color) = brush {
self.strokes.push((width, *color));
}
}
fn push_transform(&mut self, transform: Affine) {
self.transforms.push(transform);
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
}
fn paint_bg(w: &mut ButtonWidget, theme: Option<&frust_theme::Theme>) -> (f64, Color) {
let mut rec = RRectRecorder::default();
let mut ctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(t),
None => PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)),
};
w.paint(&mut ctx, &mut rec);
*rec.rrects.first().expect("button paints its background")
}
#[test]
fn unthemed_paint_uses_fallback_constants() {
let mut w = widget();
assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL));
w.pressed = true;
assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL_PRESSED));
}
#[test]
fn themed_paint_resolves_primary_and_shape_small() {
let theme = frust_theme::Theme::neutral();
let mut w = widget();
let (radius, color) = paint_bg(&mut w, Some(&theme));
assert_eq!(color, theme.scheme().primary, "resting fill is primary");
assert_eq!(radius, theme.shape.small, "radius is shape.small (8dp)");
w.pressed = true;
let (_, pressed) = paint_bg(&mut w, Some(&theme));
assert_eq!(
pressed,
pressed_overlay(theme.scheme().primary, PRESSED_DARKEN)
);
}
#[test]
fn move_back_inside_then_up_fires() {
let mut w = widget();
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 20.0, 10.0));
assert!(w.pressed);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 10.0));
assert_eq!(state.presses, 1);
}
fn styled_widget(style: ButtonStyle) -> ButtonWidget {
let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).style(style);
let mut counter = 0u64;
View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
}
#[test]
fn default_style_is_primary_and_matches_pre_task22_rendering() {
assert_eq!(ButtonStyle::default(), ButtonStyle::Primary);
let mut w = widget();
assert_eq!(w.style, ButtonStyle::Primary);
assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL));
let theme = frust_theme::Theme::neutral();
let mut w2 = widget();
assert_eq!(paint_bg(&mut w2, Some(&theme)).1, theme.scheme().primary);
}
#[test]
fn secondary_style_paints_raised_surface_and_outline_border() {
let theme = frust_theme::Theme::neutral();
let mut w = styled_widget(ButtonStyle::Secondary);
let mut rec = RRectRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
w.paint(&mut ctx, &mut rec);
assert_eq!(rec.rrects[0].1, theme.scheme().surface_container_high);
assert_eq!(rec.strokes[0].1, theme.scheme().outline);
}
#[test]
fn ghost_style_is_transparent_at_rest_and_washes_on_press() {
let theme = frust_theme::Theme::neutral();
let mut w = styled_widget(ButtonStyle::Ghost);
let mut rec = RRectRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
w.paint(&mut ctx, &mut rec);
assert!(
rec.rrects.is_empty(),
"a transparent resting fill paints no rect"
);
w.pressed = true;
let mut rec2 = RRectRecorder::default();
let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
w.paint(&mut ctx2, &mut rec2);
assert_eq!(
rec2.rrects[0].1,
with_alpha(theme.scheme().on_surface, PRESSED_OPACITY)
);
}
#[test]
fn danger_style_paints_error_border_and_error_faint_pressed_wash() {
let theme = frust_theme::Theme::neutral();
let mut w = styled_widget(ButtonStyle::Danger);
let mut rec = RRectRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
w.paint(&mut ctx, &mut rec);
assert!(rec.rrects.is_empty(), "Danger is transparent at rest");
assert_eq!(rec.strokes[0].1, theme.scheme().error);
w.pressed = true;
let mut rec2 = RRectRecorder::default();
let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
w.paint(&mut ctx2, &mut rec2);
assert_eq!(rec2.rrects[0].1, theme.scheme().error_container);
}
#[test]
fn per_style_fill_border_label_hold_across_baselines() {
let baselines = [frust_theme::Theme::neutral()];
for theme in &baselines {
let scheme = theme.scheme();
let mut secondary = styled_widget(ButtonStyle::Secondary);
assert_eq!(
ButtonStyle::Secondary.label_role(),
ThemeTextColor::OnSurface
);
let mut rec = RRectRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
secondary.paint(&mut ctx, &mut rec);
assert_eq!(rec.rrects[0].1, scheme.surface_container_high);
assert_eq!(rec.strokes[0].1, scheme.outline);
assert_eq!(
ButtonStyle::Ghost.label_role(),
ThemeTextColor::OnSurfaceVariant
);
let mut ghost = styled_widget(ButtonStyle::Ghost);
let mut rec_g = RRectRecorder::default();
let mut ctx_g = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
ghost.paint(&mut ctx_g, &mut rec_g);
assert!(rec_g.rrects.is_empty());
assert_eq!(ButtonStyle::Danger.label_role(), ThemeTextColor::Error);
let mut danger = styled_widget(ButtonStyle::Danger);
danger.pressed = true;
let mut rec_d = RRectRecorder::default();
let mut ctx_d = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
danger.paint(&mut ctx_d, &mut rec_d);
assert_eq!(rec_d.rrects[0].1, scheme.error_container);
}
}
#[test]
fn icon_style_layout_is_square() {
let mut w = styled_widget(ButtonStyle::Icon);
let mut text_ctx = frust_text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(size.width, size.height, "Icon style must be square");
}
#[test]
fn small_uses_reduced_padding() {
let normal_view = button::<Counter, _>("go", |_: &mut Counter| {});
let small_view = button::<Counter, _>("go", |_: &mut Counter| {}).small();
let mut counter = 0u64;
let mut normal_w = View::<Counter>::build(&normal_view, &mut BuildCtx::new(&mut counter));
let mut small_w = View::<Counter>::build(&small_view, &mut BuildCtx::new(&mut counter));
let mut text_ctx = frust_text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
let normal_size =
normal_w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
let small_size = small_w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert!(
small_size.width < normal_size.width && small_size.height < normal_size.height,
"small() must produce a smaller laid-out box: small={small_size:?} normal={normal_size:?}"
);
}
fn layout_with(w: &mut ButtonWidget, bc: &BoxConstraints) -> (Size, Point) {
let mut text_ctx = frust_text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
let size = w.layout(&mut lctx, bc);
(size, w.label.origin())
}
#[test]
fn natural_width_button_label_origin_is_unchanged_by_default() {
let view = button::<Counter, _>("go", |_: &mut Counter| {});
let mut counter = 0u64;
let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
let (size, origin) = layout_with(&mut w, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert!(size.width < 200.0, "button must not have been stretched");
assert_eq!(origin, Point::new(PAD_X, PAD_Y));
}
#[test]
fn stretched_button_defaults_to_centered_label() {
let view = button::<Counter, _>("go", |_: &mut Counter| {});
let mut counter = 0u64;
let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
let bc = BoxConstraints::new(Size::new(200.0, 0.0), Size::new(200.0, 200.0));
let (size, origin) = layout_with(&mut w, &bc);
assert_eq!(size.width, 200.0, "min forces the full stretched width");
let label_width = w.label.size().width;
let expected_x = (size.width - label_width) / 2.0;
assert!(
(origin.x - expected_x).abs() < 1e-6,
"stretched button must default to a centered label: got {origin:?}, expected x={expected_x}"
);
assert_eq!(origin.y, PAD_Y);
}
#[test]
fn explicit_leading_alignment_overrides_the_stretched_default() {
let view =
button::<Counter, _>("go", |_: &mut Counter| {}).label_alignment(Alignment::TOP_LEFT);
let mut counter = 0u64;
let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
let bc = BoxConstraints::new(Size::new(200.0, 0.0), Size::new(200.0, 200.0));
let (size, origin) = layout_with(&mut w, &bc);
assert_eq!(size.width, 200.0, "min forces the full stretched width");
assert_eq!(
origin,
Point::new(PAD_X, PAD_Y),
"an explicit leading alignment pins at the padding even when stretched"
);
}
#[test]
fn explicit_center_alignment_also_centers_vertically_when_stretched() {
let view =
button::<Counter, _>("go", |_: &mut Counter| {}).label_alignment(Alignment::CENTER);
let mut counter = 0u64;
let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
let bc = BoxConstraints::tight(Size::new(200.0, 100.0));
let (size, origin) = layout_with(&mut w, &bc);
assert_eq!(size, Size::new(200.0, 100.0));
assert!(
origin.y > PAD_Y,
"an explicit CENTER alignment must also center vertically: got {origin:?}"
);
}
#[test]
fn icon_style_ignores_explicit_label_alignment() {
let plain = button::<Counter, _>("i", |_: &mut Counter| {}).style(ButtonStyle::Icon);
let with_alignment = button::<Counter, _>("i", |_: &mut Counter| {})
.style(ButtonStyle::Icon)
.label_alignment(Alignment::TOP_LEFT);
let mut counter = 0u64;
let mut plain_w = View::<Counter>::build(&plain, &mut BuildCtx::new(&mut counter));
let mut aligned_w =
View::<Counter>::build(&with_alignment, &mut BuildCtx::new(&mut counter));
let bc = BoxConstraints::loose(Size::new(200.0, 200.0));
let (plain_size, plain_origin) = layout_with(&mut plain_w, &bc);
let (aligned_size, aligned_origin) = layout_with(&mut aligned_w, &bc);
assert_eq!(
plain_size.width, plain_size.height,
"Icon style must be square"
);
assert_eq!(
(plain_size, plain_origin),
(aligned_size, aligned_origin),
"an explicit label_alignment must be ignored under ButtonStyle::Icon"
);
}
#[test]
fn loading_suppresses_on_press_and_reports_disabled() {
let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).loading(true);
let mut counter = 0u64;
let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(state.presses, 0, "loading must suppress on_press");
}
#[test]
fn disabled_suppresses_on_press() {
let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).disabled(true);
let mut counter = 0u64;
let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(state.presses, 0, "disabled must suppress on_press");
}
#[test]
fn disabled_blocks_focus_and_press_suppression() {
let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).disabled(true);
let mut counter = 0u64;
let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
let mut state = Counter::default();
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
let result = w.event(&mut ctx, &ev(PointerPhase::Down, 10.0, 10.0));
assert!(
matches!(result, EventResult::Ignored),
"disabled must return Ignored, not Handled"
);
assert!(!w.captured, "disabled must not capture pointer");
assert!(!w.pressed, "disabled must not set pressed state");
}
#[test]
fn disabled_dims_appearance_by_alpha_multiplication() {
let theme = frust_theme::Theme::neutral();
let mut w = widget();
w.disabled = true;
let mut rec = RRectRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
w.paint(&mut ctx, &mut rec);
let undimmed = theme.scheme().primary;
let dimmed = disabled_alpha(undimmed);
assert_eq!(
rec.rrects[0].1, dimmed,
"disabled button must dim the fill by multiplying alpha: got {:?}, expected {:?}",
rec.rrects[0].1, dimmed
);
}
#[test]
fn disabled_and_loading_interaction() {
let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1)
.loading(true)
.disabled(true);
let mut counter = 0u64;
let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(state.presses, 0, "disabled+loading must suppress on_press");
assert!(w.loading, "loading flag is preserved");
assert!(w.disabled, "disabled flag is preserved");
}
#[test]
fn loading_spinner_freezes_and_stops_requesting_frames_under_reduce_motion() {
let mut w = widget();
w.loading = true;
let mut theme = frust_theme::Theme::neutral();
theme.motion.reduce_motion = false;
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
let mut scene = RRectRecorder::default();
w.paint(&mut ctx, &mut scene);
assert!(
ctx.needs_frame(),
"with reduce_motion off, the loading spinner must keep requesting frames"
);
assert!(
ctx.needs_frame_paced_only(),
"the loading spinner is a CosmeticLoop request — the frame gate must be able to pace it"
);
theme.motion.reduce_motion = true;
let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
let mut scene2 = RRectRecorder::default();
w.paint(&mut ctx2, &mut scene2);
assert!(
!ctx2.needs_frame(),
"with reduce_motion on, the loading spinner must stop requesting frames"
);
}
#[test]
fn press_scale_timeline_down_mid_up_cancel() {
let mut w = widget();
let mut state = Counter::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
let down_timing = resolve_press_timing(None, true);
assert!(
w.press.advance(FrameTime::from_nanos(0), down_timing),
"the seed frame must still report animating"
);
let mid = FrameTime::from_nanos(50_000_000);
w.press.advance(mid, down_timing);
assert!(
w.press.value() < 1.0,
"mid-press the scale must read below 1.0, got {}",
w.press.value()
);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
assert_eq!(state.presses, 1);
let up_timing = resolve_press_timing(None, false);
let mut t = mid.as_nanos();
let mut still_animating = true;
for _ in 0..1000 {
still_animating = w.press.advance(FrameTime::from_nanos(t), up_timing);
if !still_animating {
break;
}
t += 1_000_000; }
assert!(
!still_animating,
"the release animation must settle within a bounded number of steps"
);
assert!(
(w.press.value() - REST_SCALE).abs() < 1e-6,
"Up must restore to REST_SCALE, got {}",
w.press.value()
);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
assert_eq!(state.presses, 1, "Cancel must not fire");
assert_eq!(w.press.target, REST_SCALE, "Cancel retargets to rest");
}
}