use std::rc::Rc;
use frust::Theme;
use frust::authoring::text::{TextContext, TextLayout, TextStyle};
use frust::authoring::{
Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
View, Widget,
};
use kurbo::{Point, RoundedRect, Shape, Size};
use peniko::{Brush, Color};
use crate::button::motion::{ContentPadding, RadiusPaddingMotion};
use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
use crate::press::presses;
fn icon_glyph_size(size: IconButtonSize) -> f64 {
match size {
IconButtonSize::Xs => 20.0,
IconButtonSize::Sm | IconButtonSize::Md => 24.0,
IconButtonSize::Lg => 32.0,
IconButtonSize::Xl => 40.0,
}
}
fn visual_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
use IconButtonWidth::{Narrow, Standard, Wide};
match size {
IconButtonSize::Xs => match width {
Standard => Size::new(32.0, 32.0),
Narrow => Size::new(28.0, 32.0),
Wide => Size::new(40.0, 32.0),
},
IconButtonSize::Sm => match width {
Standard => Size::new(40.0, 40.0),
Narrow => Size::new(32.0, 40.0),
Wide => Size::new(52.0, 40.0),
},
IconButtonSize::Md => match width {
Standard => Size::new(56.0, 56.0),
Narrow => Size::new(48.0, 56.0),
Wide => Size::new(72.0, 56.0),
},
IconButtonSize::Lg => match width {
Standard => Size::new(96.0, 96.0),
Narrow => Size::new(64.0, 96.0),
Wide => Size::new(128.0, 96.0),
},
IconButtonSize::Xl => match width {
Standard => Size::new(136.0, 136.0),
Narrow => Size::new(104.0, 136.0),
Wide => Size::new(184.0, 136.0),
},
}
}
fn theme_target_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
use IconButtonWidth::{Narrow, Standard, Wide};
match size {
IconButtonSize::Xs => Size::new(48.0, 48.0),
IconButtonSize::Sm => match width {
Standard | Narrow => Size::new(48.0, 48.0),
Wide => Size::new(52.0, 48.0),
},
IconButtonSize::Md => match width {
Standard => Size::new(56.0, 56.0),
Narrow => Size::new(48.0, 56.0),
Wide => Size::new(72.0, 56.0),
},
IconButtonSize::Lg => match width {
Standard => Size::new(96.0, 96.0),
Narrow => Size::new(64.0, 96.0),
Wide => Size::new(128.0, 96.0),
},
IconButtonSize::Xl => match width {
Standard => Size::new(136.0, 136.0),
Narrow => Size::new(104.0, 136.0),
Wide => Size::new(184.0, 136.0),
},
}
}
fn target_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
let visual = visual_size(size, width);
let target = theme_target_size(size, width);
Size::new(
target.width.max(visual.width),
target.height.max(visual.height),
)
}
fn radius_rest_round(size: IconButtonSize) -> f64 {
match size {
IconButtonSize::Xs => 16.0,
IconButtonSize::Sm => 20.0,
IconButtonSize::Md => 28.0,
IconButtonSize::Lg => 48.0,
IconButtonSize::Xl => 68.0,
}
}
fn radius_rest_square(size: IconButtonSize) -> f64 {
match size {
IconButtonSize::Xs => 8.0,
IconButtonSize::Sm => 10.0,
IconButtonSize::Md => 14.0,
IconButtonSize::Lg => 24.0,
IconButtonSize::Xl => 34.0,
}
}
fn radius_pressed(size: IconButtonSize) -> f64 {
match size {
IconButtonSize::Xs => 6.0,
IconButtonSize::Sm => 8.0,
IconButtonSize::Md => 11.0,
IconButtonSize::Lg => 19.0,
IconButtonSize::Xl => 27.0,
}
}
fn radius_hovered(size: IconButtonSize) -> f64 {
match size {
IconButtonSize::Xs => 10.0,
IconButtonSize::Sm => 12.0,
IconButtonSize::Md => 16.0,
IconButtonSize::Lg => 28.0,
IconButtonSize::Xl => 40.0,
}
}
fn rest_variant(is_toggle: bool, is_selected: bool, base: IconButtonShape) -> IconButtonShape {
if is_toggle && is_selected {
base.flipped()
} else {
base
}
}
fn resting_radius(size: IconButtonSize, variant: IconButtonShape) -> f64 {
match variant {
IconButtonShape::Round => radius_rest_round(size),
IconButtonShape::Square => radius_rest_square(size),
}
}
fn effective_radius(
size: IconButtonSize,
base_shape: IconButtonShape,
is_toggle: bool,
is_selected: bool,
state: InteractionState,
) -> f64 {
let variant = rest_variant(is_toggle, is_selected, base_shape);
if state.pressed {
radius_pressed(size)
} else if state.hovered {
radius_hovered(size)
} else {
resting_radius(size, variant)
}
}
const OUTLINE_WIDTH: f64 = 1.0;
const PATH_TOLERANCE: f64 = 0.1;
const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
#[derive(Clone, Copy, Debug, PartialEq)]
struct IconButtonColors {
container: Color,
content: Color,
outline: Option<Color>,
}
fn resolve_colors(
theme: Option<&Theme>,
variant: IconButtonVariant,
selected: bool,
) -> IconButtonColors {
let (
primary,
on_primary,
secondary_container,
on_secondary_container,
on_surface_variant,
outline,
) = match theme {
Some(theme) => {
let s = theme.scheme();
(
s.primary,
s.on_primary,
s.secondary_container,
s.on_secondary_container,
s.on_surface_variant,
s.outline,
)
}
None => (
PRIMARY,
ON_PRIMARY,
SECONDARY_CONTAINER,
ON_SECONDARY_CONTAINER,
ON_SURFACE_VARIANT,
OUTLINE,
),
};
match variant {
IconButtonVariant::Standard => IconButtonColors {
container: Color::TRANSPARENT,
content: if selected {
primary
} else {
on_surface_variant
},
outline: None,
},
IconButtonVariant::Filled => IconButtonColors {
container: primary,
content: on_primary,
outline: None,
},
IconButtonVariant::Tonal => IconButtonColors {
container: secondary_container,
content: on_secondary_container,
outline: None,
},
IconButtonVariant::Outlined => IconButtonColors {
container: Color::TRANSPARENT,
content: primary,
outline: Some(outline),
},
}
}
fn badge_colors(theme: Option<&Theme>) -> (Color, Color) {
match theme {
Some(theme) => (theme.scheme().primary, theme.scheme().on_primary),
None => (PRIMARY, ON_PRIMARY),
}
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
const ZERO_PADDING: ContentPadding = ContentPadding::symmetric(0.0);
#[derive(Clone, Debug, PartialEq)]
pub enum BadgeValue {
Count(u32),
Text(String),
}
#[derive(Clone, Debug, PartialEq)]
enum ResolvedBadge {
None,
Dot,
Label(String),
}
fn resolve_badge(value: &Option<BadgeValue>) -> ResolvedBadge {
match value {
None => ResolvedBadge::None,
Some(BadgeValue::Count(n)) => {
let clamped = (*n).min(999_999);
if clamped == 0 {
ResolvedBadge::Dot
} else {
ResolvedBadge::Label(clamped.to_string())
}
}
Some(BadgeValue::Text(s)) => {
if s.is_empty() {
ResolvedBadge::None
} else {
ResolvedBadge::Label(s.clone())
}
}
}
}
const DOT_DIAMETER: f64 = 8.0;
const BADGE_HEIGHT: f64 = 16.0;
const BADGE_H_PAD: f64 = 4.0;
const BADGE_TEXT_SIZE: f32 = 11.0;
struct BadgeRun {
content: String,
style: TextStyle,
layout: Option<TextLayout>,
}
fn badge_style(theme: Option<&Theme>) -> TextStyle {
let mut style = TextStyle::new(BADGE_TEXT_SIZE, Color::BLACK);
if let Some(theme) = theme {
style.family = theme.type_scale.label_small.family.clone();
}
style
}
impl BadgeRun {
fn new() -> Self {
Self {
content: String::new(),
style: badge_style(None),
layout: None,
}
}
fn set_content(&mut self, content: &str) {
if self.content != content {
self.content = content.to_string();
self.layout = None;
}
}
fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
if self.style != *style {
self.style = style.clone();
self.layout = None;
}
if let Some(layout) = &self.layout {
return layout.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, &self.style, None);
let size = laid.size();
self.layout = Some(laid);
size
}
fn size(&self) -> Size {
self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IconButtonVariant {
#[default]
Standard,
Filled,
Tonal,
Outlined,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IconButtonSize {
Xs,
#[default]
Sm,
Md,
Lg,
Xl,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IconButtonShape {
#[default]
Round,
Square,
}
impl IconButtonShape {
fn flipped(self) -> Self {
match self {
Self::Round => Self::Square,
Self::Square => Self::Round,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IconButtonWidth {
#[default]
Standard,
Narrow,
Wide,
}
type OnPress<State> = Rc<dyn Fn(&mut State)>;
pub struct IconButtonView<State: 'static> {
icon: AnyView<State>,
selected_icon: Option<AnyView<State>>,
variant: IconButtonVariant,
size: IconButtonSize,
shape: IconButtonShape,
width: IconButtonWidth,
is_selected: Option<bool>,
enabled: bool,
haptic: HapticSignal,
badge_value: Option<BadgeValue>,
suppress_ink: bool,
semantic_label: Option<String>,
on_press: OnPress<State>,
}
pub fn icon_button<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
on_press: F,
) -> IconButtonView<State> {
IconButtonView {
icon,
selected_icon: None,
variant: IconButtonVariant::default(),
size: IconButtonSize::default(),
shape: IconButtonShape::default(),
width: IconButtonWidth::default(),
is_selected: None,
enabled: true,
haptic: HapticSignal::None,
badge_value: None,
suppress_ink: false,
semantic_label: None,
on_press: Rc::new(on_press),
}
}
#[allow(non_snake_case)]
pub fn IconButton<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
on_press: F,
) -> IconButtonView<State> {
icon_button(icon, on_press)
}
pub fn filled_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
on_press: F,
) -> IconButtonView<State> {
icon_button(icon, on_press).variant(IconButtonVariant::Filled)
}
pub fn tonal_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
on_press: F,
) -> IconButtonView<State> {
icon_button(icon, on_press).variant(IconButtonVariant::Tonal)
}
pub fn outlined_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
on_press: F,
) -> IconButtonView<State> {
icon_button(icon, on_press).variant(IconButtonVariant::Outlined)
}
impl<State: 'static> IconButtonView<State> {
pub fn variant(mut self, variant: IconButtonVariant) -> Self {
self.variant = variant;
self
}
pub fn size(mut self, size: IconButtonSize) -> Self {
self.size = size;
self
}
pub fn shape(mut self, shape: IconButtonShape) -> Self {
self.shape = shape;
self
}
pub fn width(mut self, width: IconButtonWidth) -> Self {
self.width = width;
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.is_selected = Some(selected);
self
}
pub fn selected_icon(mut self, icon: AnyView<State>) -> Self {
self.selected_icon = Some(icon);
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn haptic(mut self, haptic: HapticSignal) -> Self {
self.haptic = haptic;
self
}
pub fn badge(mut self, value: BadgeValue) -> Self {
self.badge_value = Some(value);
self
}
pub fn suppress_ink(mut self, suppress: bool) -> Self {
self.suppress_ink = suppress;
self
}
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
fn active_icon(&self) -> &AnyView<State> {
let selected = self.is_selected.unwrap_or(false);
if selected {
self.selected_icon.as_ref().unwrap_or(&self.icon)
} else {
&self.icon
}
}
fn is_toggle(&self) -> bool {
self.is_selected.is_some() || self.selected_icon.is_some()
}
}
pub struct IconButtonWidget {
variant: IconButtonVariant,
size: IconButtonSize,
shape: IconButtonShape,
width: IconButtonWidth,
is_selected: Option<bool>,
is_toggle: bool,
enabled: bool,
haptic: HapticSignal,
badge: ResolvedBadge,
suppress_ink: bool,
semantic_label: Option<String>,
icon: ChildPod,
badge_run: BadgeRun,
badge_size: Size,
visual: Size,
visual_origin: Point,
state: InteractionState,
captured: bool,
motion: RadiusPaddingMotion,
on_press: frust::authoring::ErasedCallback,
}
impl<State: 'static> View<State> for IconButtonView<State> {
type Element = IconButtonWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> IconButtonWidget {
IconButtonWidget {
variant: self.variant,
size: self.size,
shape: self.shape,
width: self.width,
is_selected: self.is_selected,
is_toggle: self.is_toggle(),
enabled: self.enabled,
haptic: self.haptic,
badge: resolve_badge(&self.badge_value),
suppress_ink: self.suppress_ink,
semantic_label: self.semantic_label.clone(),
icon: frust::authoring::build_child(self.active_icon(), ctx),
badge_run: BadgeRun::new(),
badge_size: Size::ZERO,
visual: Size::ZERO,
visual_origin: Point::ZERO,
state: InteractionState::new(),
captured: false,
motion: RadiusPaddingMotion::new(),
on_press: frust::authoring::erase_callback(&self.on_press),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut IconButtonWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_press = frust::authoring::erase_callback(&self.on_press);
element.haptic = self.haptic;
element.semantic_label = self.semantic_label.clone();
let mut flags = ChangeFlags::NONE;
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.width != self.width {
element.width = self.width;
flags |= ChangeFlags::LAYOUT | 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.suppress_ink != self.suppress_ink {
element.suppress_ink = self.suppress_ink;
flags |= ChangeFlags::PAINT;
}
let is_toggle = self.is_toggle();
if element.is_toggle != is_toggle || element.is_selected != self.is_selected {
element.is_toggle = is_toggle;
element.is_selected = self.is_selected;
flags |= ChangeFlags::PAINT;
}
let next_badge = resolve_badge(&self.badge_value);
if element.badge != next_badge {
element.badge = next_badge;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags |= frust::authoring::rebuild_child(
prev.active_icon(),
self.active_icon(),
&mut element.icon,
ctx,
);
flags
}
fn teardown(&self, element: &mut IconButtonWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(self.active_icon(), &mut element.icon, ctx);
}
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
fn inset_stroke_rect(size: Size, radius: f64, width: f64) -> RoundedRect {
let half = width / 2.0;
RoundedRect::new(
half,
half,
size.width - half,
size.height - half,
(radius - half).max(0.0),
)
}
impl Widget for IconButtonWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let visual = visual_size(self.size, self.width);
let target = target_size(self.size, self.width);
let visual_origin = Point::new(
(target.width - visual.width) / 2.0,
(target.height - visual.height) / 2.0,
);
let icon_dim = icon_glyph_size(self.size);
let icon_measured = self
.icon
.layout_child(ctx, &BoxConstraints::tight(Size::new(icon_dim, icon_dim)));
self.icon.set_origin(Point::new(
visual_origin.x + (visual.width - icon_measured.width) / 2.0,
visual_origin.y + (visual.height - icon_measured.height) / 2.0,
));
self.badge_size = match &self.badge {
ResolvedBadge::None => Size::ZERO,
ResolvedBadge::Dot => Size::new(DOT_DIAMETER, DOT_DIAMETER),
ResolvedBadge::Label(text) => {
self.badge_run.set_content(text);
let style = badge_style(Theme::from_layout_ctx(ctx));
let text_size = self.badge_run.shape(ctx, &style);
let width = (text_size.width + BADGE_H_PAD * 2.0).max(BADGE_HEIGHT);
Size::new(width, BADGE_HEIGHT)
}
};
self.visual = visual;
self.visual_origin = visual_origin;
bc.constrain(target)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let outer_origin = ctx.origin();
self.state.set_hovered(self.enabled && ctx.is_hovered());
self.state.set_focused(self.enabled && ctx.has_focus());
let selected = self.is_selected.unwrap_or(false);
let (colors, radius_target, badge_bg, badge_fg) = {
let theme = Theme::from_paint_ctx(ctx);
let colors = resolve_colors(theme, self.variant, selected);
let radius =
effective_radius(self.size, self.shape, self.is_toggle, selected, self.state);
let (bg, fg) = badge_colors(theme);
(colors, radius, bg, fg)
};
if self.motion.retarget(radius_target, ZERO_PADDING) {
ctx.request_frame();
}
if self.motion.advance(ctx.frame_time()) {
ctx.request_frame();
}
let radius = self.motion.radius();
let visual_origin = Point::new(
outer_origin.x + self.visual_origin.x,
outer_origin.y + self.visual_origin.y,
);
if colors.container.components[3] > 0.0 {
scene.fill_rounded_rect(visual_origin, self.visual, radius, colors.container);
}
if let Some(outline) = colors.outline {
let path =
inset_stroke_rect(self.visual, radius, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
scene.stroke_path(visual_origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
}
if !self.suppress_ink && self.enabled {
let opacity = self.state.resolve_opacity();
if opacity > 0.0 {
scene.fill_rounded_rect(
visual_origin,
self.visual,
radius,
with_alpha(colors.content, opacity),
);
}
}
self.icon.paint_child(ctx, scene);
match &self.badge {
ResolvedBadge::None => {}
ResolvedBadge::Dot => {
let d = DOT_DIAMETER;
let dot_origin =
Point::new(visual_origin.x + self.visual.width - d, visual_origin.y);
scene.fill_rounded_rect(dot_origin, Size::new(d, d), d / 2.0, badge_bg);
}
ResolvedBadge::Label(_) => {
let size = self.badge_size;
let pill_origin = Point::new(
visual_origin.x + self.visual.width - size.width,
visual_origin.y,
);
scene.fill_rounded_rect(pill_origin, size, size.height / 2.0, badge_bg);
let text_size = self.badge_run.size();
let text_origin = Point::new(
pill_origin.x + (size.width - text_size.width) / 2.0,
pill_origin.y + (size.height - text_size.height) / 2.0,
);
self.badge_run.paint(text_origin, badge_fg, scene);
}
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if !self.enabled {
return EventResult::Ignored;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.state.set_pressed(true);
self.captured = true;
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
let over = inside(p.position, ctx.size());
if !self.captured {
if over {
ctx.claim_hover();
ctx.set_cursor(CursorIcon::Pointer);
}
if self.state.set_hovered(over) {
ctx.request_redraw();
}
return EventResult::Ignored;
}
ctx.set_cursor(CursorIcon::Pointer);
if self.state.set_pressed(over) {
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
if inside(p.position, ctx.size()) {
if self.haptic != HapticSignal::None {
MaterialHaptics::fire(self.haptic);
}
(self.on_press)(ctx);
}
self.state.set_pressed(false);
self.captured = false;
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.state.set_pressed(false);
self.captured = false;
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::Button, |node| {
if let Some(label) = &self.semantic_label {
node.set_label(label.as_str());
}
node.set_selected(self.is_selected.unwrap_or(false));
if self.enabled {
node.add_action(Action::Click);
} else {
node.set_disabled();
}
});
}
frust::authoring::visit_children!(icon);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::FrameTime;
use frust::authoring::scene::GlyphRun;
use frust::authoring::text::FontFamily;
use frust::authoring::{BuildCtx, PointerButton, PointerEvent, View};
use frust_widgets::test_support::leaf_any;
use std::any::Any;
use std::cell::RefCell;
fn build<S: 'static>(view: &IconButtonView<S>) -> IconButtonWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
fn settle_motion(motion: &mut RadiusPaddingMotion) {
let mut t = 0.0;
for _ in 0..600 {
t += 1.0 / 60.0;
if !motion.advance(ft_secs(t)) {
return;
}
}
panic!("the press morph never settled");
}
fn settle_morph(widget: &mut IconButtonWidget) {
settle_motion(&mut widget.motion);
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
strokes: Vec<(Point, f64, Color)>,
runs: Vec<(Point, Color, usize)>,
}
impl PaintScene for Recorder {
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((o, s, radius, color));
}
fn draw_shadow(&mut self, _o: Point, _s: Size, _radius: f64, _std_dev: f64, _color: Color) {
}
fn stroke_path(&mut self, o: Point, _path: &kurbo::BezPath, width: f64, brush: &Brush) {
let color = match brush {
Brush::Solid(c) => *c,
_ => Color::TRANSPARENT,
};
self.strokes.push((o, width, color));
}
fn draw_glyph_run(&mut self, run: GlyphRun) {
let t = run.transform.translation();
let color = match &run.brush {
Brush::Solid(c) => *c,
_ => Color::TRANSPARENT,
};
self.runs
.push((Point::new(t.x, t.y), color, run.glyphs.len()));
}
}
fn layout_with(widget: &mut IconButtonWidget, max: f64) -> Size {
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(max, max)))
}
fn paint_at(widget: &mut IconButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, size);
if let Some(theme) = theme {
pctx = pctx.with_theme(theme as &dyn Any);
}
widget.paint(&mut pctx, &mut rec);
rec
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn dispatch<S: 'static>(
widget: &mut IconButtonWidget,
state: &mut S,
size: Size,
event: &InputEvent,
) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
widget.event(&mut ctx, event);
}
#[test]
fn defaults_match_the_reference_constructor_defaults() {
let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {});
assert_eq!(view.variant, IconButtonVariant::Standard);
assert_eq!(view.size, IconButtonSize::Sm);
assert_eq!(view.shape, IconButtonShape::Round);
assert_eq!(view.width, IconButtonWidth::Standard);
assert!(view.enabled);
assert!(!view.suppress_ink);
assert_eq!(view.haptic, HapticSignal::None);
assert!(!view.is_toggle());
}
#[test]
fn the_named_constructors_map_onto_the_reference_variants() {
assert_eq!(
filled_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
IconButtonVariant::Filled
);
assert_eq!(
tonal_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
IconButtonVariant::Tonal
);
assert_eq!(
outlined_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
IconButtonVariant::Outlined
);
}
const ALL_SIZES: [IconButtonSize; 5] = [
IconButtonSize::Xs,
IconButtonSize::Sm,
IconButtonSize::Md,
IconButtonSize::Lg,
IconButtonSize::Xl,
];
#[test]
fn icon_glyph_sizes_match_the_reference_table() {
let expected = [
(IconButtonSize::Xs, 20.0),
(IconButtonSize::Sm, 24.0),
(IconButtonSize::Md, 24.0),
(IconButtonSize::Lg, 32.0),
(IconButtonSize::Xl, 40.0),
];
for (size, icon) in expected {
assert_eq!(icon_glyph_size(size), icon, "{size:?}");
}
}
#[test]
fn visual_sizes_match_the_reference_table_for_every_size_and_width() {
use IconButtonWidth::{Narrow, Standard, Wide};
let expected: [(IconButtonSize, Size, Size, Size); 5] = [
(
IconButtonSize::Xs,
Size::new(32.0, 32.0),
Size::new(28.0, 32.0),
Size::new(40.0, 32.0),
),
(
IconButtonSize::Sm,
Size::new(40.0, 40.0),
Size::new(32.0, 40.0),
Size::new(52.0, 40.0),
),
(
IconButtonSize::Md,
Size::new(56.0, 56.0),
Size::new(48.0, 56.0),
Size::new(72.0, 56.0),
),
(
IconButtonSize::Lg,
Size::new(96.0, 96.0),
Size::new(64.0, 96.0),
Size::new(128.0, 96.0),
),
(
IconButtonSize::Xl,
Size::new(136.0, 136.0),
Size::new(104.0, 136.0),
Size::new(184.0, 136.0),
),
];
for (size, default, narrow, wide) in expected {
assert_eq!(visual_size(size, Standard), default, "{size:?} default");
assert_eq!(visual_size(size, Narrow), narrow, "{size:?} narrow");
assert_eq!(visual_size(size, Wide), wide, "{size:?} wide");
}
}
#[test]
fn target_sizes_match_the_reference_table_for_every_size_and_width() {
use IconButtonWidth::{Narrow, Standard, Wide};
let expected: [(IconButtonSize, Size, Size, Size); 5] = [
(
IconButtonSize::Xs,
Size::new(48.0, 48.0),
Size::new(48.0, 48.0),
Size::new(48.0, 48.0),
),
(
IconButtonSize::Sm,
Size::new(48.0, 48.0),
Size::new(48.0, 48.0),
Size::new(52.0, 48.0),
),
(
IconButtonSize::Md,
Size::new(56.0, 56.0),
Size::new(48.0, 56.0),
Size::new(72.0, 56.0),
),
(
IconButtonSize::Lg,
Size::new(96.0, 96.0),
Size::new(64.0, 96.0),
Size::new(128.0, 96.0),
),
(
IconButtonSize::Xl,
Size::new(136.0, 136.0),
Size::new(104.0, 136.0),
Size::new(184.0, 136.0),
),
];
for (size, default, narrow, wide) in expected {
assert_eq!(target_size(size, Standard), default, "{size:?} default");
assert_eq!(target_size(size, Narrow), narrow, "{size:?} narrow");
assert_eq!(target_size(size, Wide), wide, "{size:?} wide");
}
}
#[test]
fn xs_and_sm_visual_is_smaller_than_the_48dp_target_but_the_target_never_is() {
for size in ALL_SIZES {
for width in [
IconButtonWidth::Standard,
IconButtonWidth::Narrow,
IconButtonWidth::Wide,
] {
let visual = visual_size(size, width);
let target = target_size(size, width);
assert!(
target.width >= 48.0 && target.height >= 48.0,
"{size:?}/{width:?} target below 48dp"
);
assert!(target.width >= visual.width && target.height >= visual.height);
}
}
assert!(visual_size(IconButtonSize::Xs, IconButtonWidth::Standard).width < 48.0);
assert!(visual_size(IconButtonSize::Sm, IconButtonWidth::Standard).width < 48.0);
}
#[test]
fn radius_tables_match_the_reference() {
let expected: [(IconButtonSize, f64, f64, f64, f64); 5] = [
(IconButtonSize::Xs, 16.0, 8.0, 6.0, 10.0),
(IconButtonSize::Sm, 20.0, 10.0, 8.0, 12.0),
(IconButtonSize::Md, 28.0, 14.0, 11.0, 16.0),
(IconButtonSize::Lg, 48.0, 24.0, 19.0, 28.0),
(IconButtonSize::Xl, 68.0, 34.0, 27.0, 40.0),
];
for (size, round, square, pressed, hovered) in expected {
assert_eq!(radius_rest_round(size), round, "{size:?} round");
assert_eq!(radius_rest_square(size), square, "{size:?} square");
assert_eq!(radius_pressed(size), pressed, "{size:?} pressed");
assert_eq!(radius_hovered(size), hovered, "{size:?} hovered");
}
}
#[test]
fn a_toggle_flips_shape_only_while_selected() {
assert_eq!(
rest_variant(true, true, IconButtonShape::Round),
IconButtonShape::Square
);
assert_eq!(
rest_variant(true, true, IconButtonShape::Square),
IconButtonShape::Round
);
assert_eq!(
rest_variant(true, false, IconButtonShape::Round),
IconButtonShape::Round
);
assert_eq!(
rest_variant(false, true, IconButtonShape::Round),
IconButtonShape::Round,
"not a toggle at all: no flip even if selected is somehow true"
);
}
#[test]
fn effective_radius_precedence_is_pressed_then_hovered_then_resting() {
let mut state = InteractionState::new();
assert_eq!(
effective_radius(
IconButtonSize::Sm,
IconButtonShape::Round,
false,
false,
state
),
radius_rest_round(IconButtonSize::Sm)
);
state.set_hovered(true);
assert_eq!(
effective_radius(
IconButtonSize::Sm,
IconButtonShape::Round,
false,
false,
state
),
radius_hovered(IconButtonSize::Sm)
);
state.set_pressed(true);
assert_eq!(
effective_radius(
IconButtonSize::Sm,
IconButtonShape::Round,
false,
false,
state
),
radius_pressed(IconButtonSize::Sm)
);
}
#[test]
fn unthemed_colors_match_the_reference_variant_table() {
let standard = resolve_colors(None, IconButtonVariant::Standard, false);
assert_eq!(standard.container, Color::TRANSPARENT);
assert_eq!(standard.content, ON_SURFACE_VARIANT);
assert_eq!(standard.outline, None);
let standard_selected = resolve_colors(None, IconButtonVariant::Standard, true);
assert_eq!(
standard_selected.content, PRIMARY,
"selected standard swaps content to primary — the one color the toggle changes"
);
let filled = resolve_colors(None, IconButtonVariant::Filled, false);
assert_eq!(filled.container, PRIMARY);
assert_eq!(filled.content, ON_PRIMARY);
let tonal = resolve_colors(None, IconButtonVariant::Tonal, false);
assert_eq!(tonal.container, SECONDARY_CONTAINER);
assert_eq!(tonal.content, ON_SECONDARY_CONTAINER);
let outlined = resolve_colors(None, IconButtonVariant::Outlined, false);
assert_eq!(outlined.container, Color::TRANSPARENT);
assert_eq!(outlined.content, PRIMARY);
assert_eq!(outlined.outline, Some(OUTLINE));
}
#[test]
fn only_standard_swaps_color_on_selection() {
for variant in [
IconButtonVariant::Filled,
IconButtonVariant::Tonal,
IconButtonVariant::Outlined,
] {
let unselected = resolve_colors(None, variant, false);
let selected = resolve_colors(None, variant, true);
assert_eq!(
unselected, selected,
"{variant:?} must not change color on selection"
);
}
}
#[test]
fn press_spring_matches_expressive_spatial_press() {
let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
let spring = crate::button::motion::PRESS_SPRING;
assert_eq!(spring.stiffness, token.stiffness);
assert_eq!(spring.damping_ratio, token.damping_ratio);
assert_eq!(spring.mass, 1.0);
assert_eq!(spring.stiffness, 380.0);
assert_eq!(spring.damping_ratio, 0.55);
}
#[test]
fn the_morph_snaps_on_mount_and_springs_on_a_later_retarget() {
let mut motion = RadiusPaddingMotion::new();
assert!(
!motion.retarget(20.0, ZERO_PADDING),
"the mount frame seeds, it does not animate"
);
assert_eq!(motion.radius(), 20.0);
assert!(
motion.retarget(8.0, ZERO_PADDING),
"a later retarget starts a leg"
);
assert_eq!(motion.radius(), 20.0, "the leg starts where it was");
settle_motion(&mut motion);
assert_eq!(motion.radius(), 8.0);
}
#[test]
fn a_sub_tolerance_retarget_is_ignored() {
let mut motion = RadiusPaddingMotion::new();
motion.retarget(20.0, ZERO_PADDING);
let tolerance = crate::button::motion::RETARGET_TOLERANCE;
assert!(!motion.retarget(20.0 + tolerance / 2.0, ZERO_PADDING));
assert_eq!(motion.target_radius(), 20.0);
}
#[test]
fn a_press_and_release_cycle_settles_at_the_resting_radius() {
let presses = Rc::new(RefCell::new(0u32));
let counted = presses.clone();
let view = icon_button::<(), _>(leaf_any(24.0, 24.0), move |_| {
*counted.borrow_mut() += 1;
});
let mut widget = build(&view);
let size = layout_with(&mut widget, 200.0);
paint_at(&mut widget, size, None);
settle_morph(&mut widget);
let resting = widget.motion.radius();
assert_eq!(resting, radius_rest_round(IconButtonSize::Sm));
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, 10.0, 10.0),
);
paint_at(&mut widget, size, None);
settle_morph(&mut widget);
assert_eq!(widget.motion.radius(), radius_pressed(IconButtonSize::Sm));
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, 10.0, 10.0),
);
assert_eq!(*presses.borrow(), 1, "on_press fired exactly once");
paint_at(&mut widget, size, None);
settle_morph(&mut widget);
assert_eq!(widget.motion.radius(), resting);
}
#[test]
fn badge_resolution_matches_build_badge() {
assert_eq!(resolve_badge(&None), ResolvedBadge::None);
assert_eq!(
resolve_badge(&Some(BadgeValue::Count(0))),
ResolvedBadge::Dot
);
assert_eq!(
resolve_badge(&Some(BadgeValue::Count(3))),
ResolvedBadge::Label("3".to_string())
);
assert_eq!(
resolve_badge(&Some(BadgeValue::Count(1_000_000))),
ResolvedBadge::Label("999999".to_string()),
"clamped to 999_999"
);
assert_eq!(
resolve_badge(&Some(BadgeValue::Text(String::new()))),
ResolvedBadge::None
);
assert_eq!(
resolve_badge(&Some(BadgeValue::Text("NEW".to_string()))),
ResolvedBadge::Label("NEW".to_string())
);
}
#[test]
fn a_dot_badge_paints_one_small_filled_circle_at_the_top_end_corner() {
let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(0));
let mut widget = build(&view);
let size = layout_with(&mut widget, 200.0);
let rec = paint_at(&mut widget, size, None);
let (origin, dot_size, radius, color) = rec
.rrects
.iter()
.find(|(_, s, _, _)| *s == Size::new(DOT_DIAMETER, DOT_DIAMETER))
.copied()
.expect("a dot-sized rounded rect is painted");
let _ = dot_size;
assert_eq!(radius, DOT_DIAMETER / 2.0);
assert_eq!(color, PRIMARY);
let visual = visual_size(IconButtonSize::Sm, IconButtonWidth::Standard);
let visual_origin = widget.visual_origin;
assert_eq!(origin.y, visual_origin.y);
assert_eq!(origin.x, visual_origin.x + visual.width - DOT_DIAMETER);
}
#[test]
fn a_count_badge_paints_a_labeled_pill_with_the_digit_text() {
let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
let mut widget = build(&view);
let size = layout_with(&mut widget, 200.0);
let rec = paint_at(&mut widget, size, None);
assert!(
rec.rrects.iter().any(|(_, s, r, c)| *s == widget.badge_size
&& *r == BADGE_HEIGHT / 2.0
&& *c == PRIMARY),
"the pill background is painted at primary"
);
assert!(
rec.runs
.iter()
.any(|(_, ink, glyphs)| *ink == ON_PRIMARY && *glyphs == 1),
"the badge label's one-digit glyph run is painted at on_primary"
);
}
fn theme_with_label_small(family: FontFamily) -> Theme {
let mut theme = crate::baseline();
theme.type_scale.label_small.family = family;
theme
}
fn layout_themed(widget: &mut IconButtonWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
let mut ctx =
LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
widget.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
}
#[test]
fn a_badge_label_takes_its_family_from_label_small() {
let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
let mut widget = build(&view);
let mut tcx = TextContext::new();
let probe = FontFamily::named("Icon Badge Role Probe");
layout_themed(
&mut widget,
&mut tcx,
Some(&theme_with_label_small(probe.clone())),
);
assert_eq!(widget.badge_run.style.family, probe);
assert_eq!(widget.badge_run.style.size, BADGE_TEXT_SIZE);
}
#[test]
fn without_a_theme_the_badge_label_keeps_the_unthemed_style() {
let unthemed = TextStyle::new(BADGE_TEXT_SIZE, Color::BLACK);
assert_eq!(badge_style(None), unthemed);
assert_eq!(unthemed.family, FontFamily::SystemUi);
let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
let mut widget = build(&view);
let mut tcx = TextContext::new();
layout_themed(&mut widget, &mut tcx, None);
assert_eq!(widget.badge_run.style, unthemed);
}
#[test]
fn a_theme_swap_reshapes_the_cached_badge_label() {
let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
let mut widget = build(&view);
let mut tcx = TextContext::new();
let first = theme_with_label_small(FontFamily::named("Icon Badge Swap Probe A"));
layout_themed(&mut widget, &mut tcx, Some(&first));
let settled = tcx.shape_cache_stats();
layout_themed(&mut widget, &mut tcx, Some(&first));
assert_eq!(
tcx.shape_cache_stats(),
settled,
"an unchanged theme reshapes nothing"
);
let second = theme_with_label_small(FontFamily::named("Icon Badge Swap Probe B"));
layout_themed(&mut widget, &mut tcx, Some(&second));
assert!(
tcx.shape_cache_stats().shapes > settled.shapes,
"a family swap must reshape the badge label, not serve the old face"
);
}
#[test]
fn no_badge_value_paints_no_badge_geometry() {
let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {});
let mut widget = build(&view);
let size = layout_with(&mut widget, 200.0);
let rec = paint_at(&mut widget, size, None);
assert!(
rec.rrects
.iter()
.all(|(_, s, _, _)| *s != Size::new(DOT_DIAMETER, DOT_DIAMETER)),
"no dot painted"
);
assert!(rec.runs.is_empty(), "no badge label painted");
}
#[test]
fn an_outlined_icon_button_paints_a_hairline_stroke() {
let view =
icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant(IconButtonVariant::Outlined);
let mut widget = build(&view);
let size = layout_with(&mut widget, 200.0);
let rec = paint_at(&mut widget, size, None);
assert_eq!(rec.strokes.len(), 1, "the outline hairline is stroked once");
let (_, width, color) = rec.strokes[0];
assert_eq!(width, OUTLINE_WIDTH);
assert_eq!(color, OUTLINE);
}
#[test]
fn a_filled_icon_button_paints_no_outline_stroke() {
let view =
icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant(IconButtonVariant::Filled);
let mut widget = build(&view);
let size = layout_with(&mut widget, 200.0);
let rec = paint_at(&mut widget, size, None);
assert!(rec.strokes.is_empty());
}
#[test]
fn selected_icon_becomes_active_only_while_selected() {
let base = leaf_any(20.0, 20.0);
let selected_icon = leaf_any(24.0, 24.0);
let view = icon_button::<(), _>(base, |_| {}).selected_icon(selected_icon);
assert!(view.is_toggle());
assert!(
std::ptr::eq(view.active_icon(), &view.icon),
"unselected: base icon is active"
);
let selected_view = view.selected(true);
assert!(
std::ptr::eq(
selected_view.active_icon(),
selected_view.selected_icon.as_ref().unwrap()
),
"selected: selected_icon is active"
);
}
#[test]
fn selected_without_a_selected_icon_still_counts_as_toggle() {
let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).selected(false);
assert!(view.is_toggle());
}
#[test]
fn semantics_reports_role_button_selected_and_label() {
fn logic(_s: &mut ()) -> IconButtonView<()> {
icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {})
.selected(true)
.semantic_label("Favorite")
}
let mut root: frust_core::RenderRoot<(), IconButtonView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Button)
.expect("icon button contributes a Role::Button node");
assert_eq!(node.label(), Some("Favorite"));
assert_eq!(node.is_selected(), Some(true));
assert!(node.supports_action(Action::Click));
}
#[test]
fn semantics_omits_click_action_when_disabled() {
fn logic(_s: &mut ()) -> IconButtonView<()> {
icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).enabled(false)
}
let mut root: frust_core::RenderRoot<(), IconButtonView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Button)
.expect("icon button contributes a Role::Button node");
assert!(!node.supports_action(Action::Click));
}
#[test]
fn disabled_colors_are_identical_to_enabled_colors() {
for variant in [
IconButtonVariant::Standard,
IconButtonVariant::Filled,
IconButtonVariant::Tonal,
IconButtonVariant::Outlined,
] {
let enabled = resolve_colors(None, variant, false);
let disabled = resolve_colors(None, variant, false); assert_eq!(enabled, disabled);
}
}
#[test]
fn a_disabled_button_ignores_every_pointer_event() {
let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).enabled(false);
let mut widget = build(&view);
let size = layout_with(&mut widget, 200.0);
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, 5.0, 5.0),
);
assert!(!widget.captured);
assert!(!widget.state.pressed);
}
#[test]
fn a_release_outside_bounds_does_not_fire() {
let presses = Rc::new(RefCell::new(0u32));
let counted = presses.clone();
let view = icon_button::<(), _>(leaf_any(20.0, 20.0), move |_| {
*counted.borrow_mut() += 1;
});
let mut widget = build(&view);
let size = layout_with(&mut widget, 200.0);
let mut state = ();
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Down, 5.0, 5.0),
);
dispatch(
&mut widget,
&mut state,
size,
&ev(PointerPhase::Up, 9999.0, 9999.0),
);
assert_eq!(*presses.borrow(), 0);
assert!(!widget.captured);
}
}