use std::rc::Rc;
use std::time::Duration;
use frust::authoring::{Action, Role};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget,
};
use frust::{AnimationController, ShapeScale, SpringDesc, Theme, Tween};
use kurbo::{Affine, Point, Rect, Size};
use peniko::Color;
use super::press::presses;
use super::state_layer::StateLayer;
use frust::authoring::{ThemeTextColor, ThemeTextType};
const SMALL_CONTAINER: f64 = 40.0;
const MEDIUM_CONTAINER: f64 = 56.0;
const LARGE_CONTAINER: f64 = 96.0;
const SMALL_ICON: f64 = 24.0;
const MEDIUM_ICON: f64 = 24.0;
const LARGE_ICON: f64 = 36.0;
const SMALL_RADIUS: f64 = 12.0;
const MEDIUM_RADIUS: f64 = 16.0;
const LARGE_RADIUS: f64 = 28.0;
const EXTENDED_HEIGHT: f64 = 56.0;
const EXTENDED_RADIUS_FALLBACK: f64 = MEDIUM_RADIUS;
const EXTENDED_PAD_EXPANDED: f64 = 20.0;
const EXTENDED_PAD_COLLAPSED: f64 = 16.0;
const EXTENDED_ICON: f64 = 24.0;
const EXTENDED_ICON_LABEL_GAP: f64 = 12.0;
const EXTENDED_PRESSED_SCALE: f64 = 0.97;
const FAB_PRESSED_SCALE: f64 = 0.95;
const PRESS_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 380.0,
damping_ratio: 0.55,
};
const PRESS_ANIM_PERIOD: Duration = Duration::from_millis(300);
const FLING_VELOCITY: f64 = 4.0;
const PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
const ON_PRIMARY_CONTAINER: Color = Color::from_rgb8(0x21, 0x00, 0x5D);
const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
const TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
const ON_TERTIARY_CONTAINER: Color = Color::from_rgb8(0x31, 0x11, 0x1D);
const SURFACE_CONTAINER_HIGH: Color = Color::from_rgb8(0xEC, 0xE6, 0xF0);
const SURFACE_FOREGROUND: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
const LEVEL3_SHADOW_Y_OFFSET: f64 = 4.0;
const LEVEL3_SHADOW_BLUR: f64 = 6.0;
const LEVEL4_SHADOW_Y_OFFSET: f64 = 5.0;
const LEVEL4_SHADOW_BLUR: f64 = 8.0;
const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FabSize {
Small,
Medium,
Large,
}
impl FabSize {
fn container(self) -> f64 {
match self {
FabSize::Small => SMALL_CONTAINER,
FabSize::Medium => MEDIUM_CONTAINER,
FabSize::Large => LARGE_CONTAINER,
}
}
fn icon_size(self) -> f64 {
match self {
FabSize::Small => SMALL_ICON,
FabSize::Medium => MEDIUM_ICON,
FabSize::Large => LARGE_ICON,
}
}
fn fallback_radius(self) -> f64 {
match self {
FabSize::Small => SMALL_RADIUS,
FabSize::Medium => MEDIUM_RADIUS,
FabSize::Large => LARGE_RADIUS,
}
}
fn themed_radius(self, shape: &ShapeScale) -> f64 {
match self {
FabSize::Small => shape.medium,
FabSize::Medium => shape.large,
FabSize::Large => shape.extra_large,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FabColor {
Primary,
Secondary,
Tertiary,
Surface,
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_colors(theme: Option<&Theme>, color: FabColor) -> (Color, Color) {
match theme {
Some(theme) => {
let s = theme.scheme();
match color {
FabColor::Primary => (s.primary_container, s.on_primary_container),
FabColor::Secondary => (s.secondary_container, s.on_secondary_container),
FabColor::Tertiary => (s.tertiary_container, s.on_tertiary_container),
FabColor::Surface => (s.surface_container_high, s.primary),
}
}
None => match color {
FabColor::Primary => (PRIMARY_CONTAINER, ON_PRIMARY_CONTAINER),
FabColor::Secondary => (SECONDARY_CONTAINER, ON_SECONDARY_CONTAINER),
FabColor::Tertiary => (TERTIARY_CONTAINER, ON_TERTIARY_CONTAINER),
FabColor::Surface => (SURFACE_CONTAINER_HIGH, SURFACE_FOREGROUND),
},
}
}
fn resolve_shadow(theme: Option<&Theme>, hovered: bool) -> (f64, f64, Color) {
match theme {
Some(theme) => {
let level = if hovered {
theme.elevation.level4
} else {
theme.elevation.level3
};
let shadow = level.shadow(theme.brightness);
let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
(shadow.blur_std_dev, shadow.y_offset, color)
}
None => {
if hovered {
(
LEVEL4_SHADOW_BLUR,
LEVEL4_SHADOW_Y_OFFSET,
FALLBACK_SHADOW_COLOR,
)
} else {
(
LEVEL3_SHADOW_BLUR,
LEVEL3_SHADOW_Y_OFFSET,
FALLBACK_SHADOW_COLOR,
)
}
}
}
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
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)>;
fn label_view<State: 'static>(label: String) -> AnyView<State> {
frust::authoring::any::<State, _>(
frust::text(label)
.themed_role(ThemeTextColor::OnPrimaryContainer)
.themed_family(ThemeTextType::LabelLarge),
)
}
pub struct FabView<State: 'static> {
size: FabSize,
color: FabColor,
icon: Option<AnyView<State>>,
label: Option<String>,
is_extended: bool,
label_shown: bool,
corner_radius: Option<f64>,
on_press: OnPress<State>,
}
pub fn fab<State: 'static, F: Fn(&mut State) + 'static>(
icon: impl View<State>,
on_press: F,
) -> FabView<State> {
FabView {
size: FabSize::Medium,
color: FabColor::Primary,
icon: Some(AnyView::new(icon)),
label: None,
is_extended: false,
label_shown: true,
corner_radius: None,
on_press: Rc::new(on_press),
}
}
pub fn extended_fab<State: 'static, F: Fn(&mut State) + 'static>(
label: impl Into<String>,
on_press: F,
) -> FabView<State> {
FabView {
size: FabSize::Medium,
color: FabColor::Primary,
icon: None,
label: Some(label.into()),
is_extended: true,
label_shown: true,
corner_radius: None,
on_press: Rc::new(on_press),
}
}
impl<State: 'static> FabView<State> {
pub fn size(mut self, size: FabSize) -> Self {
self.size = size;
self
}
pub fn color(mut self, color: FabColor) -> Self {
self.color = color;
self
}
pub fn icon(mut self, icon: impl View<State>) -> Self {
self.icon = Some(AnyView::new(icon));
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn extended(mut self, extended: bool) -> Self {
self.label_shown = extended;
self
}
pub fn corner_radius(mut self, radius: Option<f64>) -> Self {
self.corner_radius = radius;
self
}
}
pub struct FabWidget {
size: FabSize,
color: FabColor,
is_extended: bool,
label_shown: bool,
corner_radius: Option<f64>,
icon: Option<ChildPod>,
visible_label: Option<ChildPod>,
label_text: Option<String>,
pressed: bool,
captured: bool,
state_layer: StateLayer,
press_anim: AnimationController,
label_anim: AnimationController,
radius_from: f64,
radius_to: f64,
radius_seeded: bool,
radius_anim: AnimationController,
on_press: frust::authoring::ErasedCallback,
}
impl<State: 'static> View<State> for FabView<State> {
type Element = FabWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> FabWidget {
let icon = self
.icon
.as_ref()
.map(|icon| frust::authoring::build_child(icon, ctx));
let visible_label = if self.is_extended {
self.label.as_ref().map(|label| {
frust::authoring::build_child(&label_view::<State>(label.clone()), ctx)
})
} else {
None
};
let mut label_anim = AnimationController::new(crate::tokens::MaterialMotion::MEDIUM_2)
.with_curve(crate::tokens::MaterialMotion::EMPHASIZED);
if self.label_shown {
label_anim.forward();
}
FabWidget {
size: self.size,
color: self.color,
is_extended: self.is_extended,
label_shown: self.label_shown,
corner_radius: self.corner_radius,
icon,
visible_label,
label_text: self.label.clone(),
pressed: false,
captured: false,
state_layer: StateLayer::new(),
press_anim: AnimationController::new(PRESS_ANIM_PERIOD),
label_anim,
radius_from: 0.0,
radius_to: 0.0,
radius_seeded: false,
radius_anim: AnimationController::new(crate::tokens::MaterialMotion::SHORT_4)
.with_curve(crate::tokens::MaterialMotion::STANDARD),
on_press: frust::authoring::erase_callback(&self.on_press),
}
}
fn rebuild(&self, prev: &Self, element: &mut FabWidget, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
element.on_press = frust::authoring::erase_callback(&self.on_press);
let mut flags = ChangeFlags::NONE;
if prev.size != self.size {
element.size = self.size;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.color != self.color {
element.color = self.color;
flags |= ChangeFlags::PAINT;
}
if prev.is_extended != self.is_extended {
element.is_extended = self.is_extended;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.label_shown != self.label_shown {
element.label_shown = self.label_shown;
if self.label_shown {
element.label_anim.forward();
} else {
element.label_anim.reverse();
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.corner_radius != self.corner_radius {
element.corner_radius = self.corner_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;
}
}
if self.is_extended {
let freshly_mounted = element.visible_label.is_none();
match (&prev.label, &self.label) {
(None, None) => {}
(Some(p), Some(n)) if p == n && prev.is_extended == self.is_extended => {}
(Some(_), Some(n)) => {
element.label_text = Some(n.clone());
if let Some(pod) = element.visible_label.as_mut() {
let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
let next_view = label_view::<State>(n.clone());
flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
} else {
element.visible_label = Some(frust::authoring::build_child(
&label_view::<State>(n.clone()),
ctx,
));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
(None, Some(n)) => {
element.label_text = Some(n.clone());
element.visible_label = Some(frust::authoring::build_child(
&label_view::<State>(n.clone()),
ctx,
));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(Some(_), None) => {
element.label_text = None;
if let Some(mut pod) = element.visible_label.take() {
let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
if freshly_mounted && element.visible_label.is_some() {
element.label_anim =
AnimationController::new(crate::tokens::MaterialMotion::MEDIUM_2)
.with_curve(crate::tokens::MaterialMotion::EMPHASIZED);
if self.label_shown {
element.label_anim.forward();
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
} else {
if prev.is_extended
&& let Some(mut pod) = element.visible_label.take()
{
let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.label != self.label {
element.label_text = self.label.clone();
flags |= ChangeFlags::PAINT;
}
}
flags
}
fn teardown(&self, element: &mut FabWidget, ctx: &mut BuildCtx<'_>) {
if let (Some(icon_view), Some(pod)) = (&self.icon, element.icon.as_mut()) {
frust::authoring::teardown_child(icon_view, pod, ctx);
}
if self.is_extended
&& let (Some(label), Some(pod)) = (&self.label, element.visible_label.as_mut())
{
let label_view = label_view::<State>(label.clone());
frust::authoring::teardown_child(&label_view, pod, ctx);
}
}
}
impl FabWidget {
fn resolve_radius_target(&self, theme: Option<&Theme>) -> f64 {
if let Some(r) = self.corner_radius
&& !self.is_extended
{
return r;
}
match theme {
Some(theme) => {
if self.is_extended {
theme.shape.large
} else {
self.size.themed_radius(&theme.shape)
}
}
None => {
if self.is_extended {
EXTENDED_RADIUS_FALLBACK
} else {
self.size.fallback_radius()
}
}
}
}
fn advance_radius(&mut self, ctx: &mut PaintCtx, target: f64, immediate: bool) -> f64 {
if !self.radius_seeded {
self.radius_from = target;
self.radius_to = target;
self.radius_seeded = true;
} else if (self.radius_to - target).abs() > f64::EPSILON {
if immediate {
self.radius_from = target;
self.radius_to = target;
self.radius_anim = AnimationController::new(Duration::ZERO);
} else {
let current = Tween::new(self.radius_from, self.radius_to)
.lerp(self.radius_anim.value_clamped());
self.radius_from = current;
self.radius_to = target;
self.radius_anim = AnimationController::new(crate::tokens::MaterialMotion::SHORT_4)
.with_curve(crate::tokens::MaterialMotion::STANDARD);
self.radius_anim.forward();
}
}
if self.radius_anim.advance(ctx.frame_time()) {
ctx.request_frame();
}
Tween::new(self.radius_from, self.radius_to).lerp(self.radius_anim.value_clamped())
}
}
impl Widget for FabWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
if !self.is_extended {
let container = self.size.container();
if let Some(icon) = self.icon.as_mut() {
let dim = self.size.icon_size();
let icon_size = icon.layout_child(ctx, &BoxConstraints::tight(Size::new(dim, dim)));
icon.set_origin(Point::new(
(container - icon_size.width) / 2.0,
(container - icon_size.height) / 2.0,
));
}
return bc.constrain(Size::new(container, container));
}
let height = EXTENDED_HEIGHT;
let t = self.label_anim.value_clamped();
let pad = Tween::new(EXTENDED_PAD_COLLAPSED, EXTENDED_PAD_EXPANDED).lerp(t);
let has_icon = self.icon.is_some();
let mut x = pad;
if let Some(icon) = self.icon.as_mut() {
let icon_size = icon.layout_child(
ctx,
&BoxConstraints::tight(Size::new(EXTENDED_ICON, EXTENDED_ICON)),
);
icon.set_origin(Point::new(x, (height - icon_size.height) / 2.0));
x += icon_size.width;
}
if let Some(label) = self.visible_label.as_mut() {
let label_size = label.layout_child(
ctx,
&BoxConstraints::loose(Size::new(f64::INFINITY, height)),
);
let gap = if has_icon {
EXTENDED_ICON_LABEL_GAP * t
} else {
0.0
};
x += gap;
label.set_origin(Point::new(x, (height - label_size.height) / 2.0));
x += label_size.width * t;
}
x += pad;
bc.constrain(Size::new(x, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let (container_color, content_color) = resolve_colors(theme, self.color);
let hovered = ctx.is_hovered();
let radius_target = self.resolve_radius_target(theme);
let (blur, y_offset, shadow_color) = resolve_shadow(theme, hovered);
self.state_layer.set_hovered(hovered);
let immediate = self.corner_radius.is_some() && !self.is_extended;
let radius = self.advance_radius(ctx, radius_target, immediate);
if self.press_anim.advance(ctx.frame_time()) {
ctx.request_frame();
}
let pressed_scale = if self.is_extended {
EXTENDED_PRESSED_SCALE
} else {
FAB_PRESSED_SCALE
};
let scale = Tween::new(1.0, pressed_scale).lerp(self.press_anim.value());
let label_before = self.label_anim.value_clamped();
let label_animating = self.label_anim.advance(ctx.frame_time());
if label_animating || self.label_anim.value_clamped() != label_before {
ctx.request_layout();
}
let o = ctx.origin();
let size = ctx.size();
let pivot = Point::new(o.x + size.width / 2.0, o.y + size.height / 2.0);
scene.push_transform(scale_about(pivot, scale));
scene.draw_shadow(
Point::new(o.x, o.y + y_offset),
size,
radius,
blur,
shadow_color,
);
scene.fill_rounded_rect(o, size, radius, container_color);
self.state_layer.paint(
ctx,
scene,
Rect::from_origin_size(o, size),
radius,
content_color,
);
if let Some(icon) = self.icon.as_mut() {
icon.paint_child(ctx, scene);
}
if self.is_extended
&& let Some(label) = self.visible_label.as_mut()
{
label.paint_child(ctx, scene);
}
scene.pop_transform();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
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.state_layer.set_pressed(true);
self.press_anim.fling(FLING_VELOCITY, PRESS_SPRING);
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
if !self.captured {
let over = inside(p.position, ctx.size());
if over {
ctx.claim_hover();
}
if self.state_layer.set_hovered(over) {
ctx.request_redraw();
}
return EventResult::Ignored;
}
let inside_now = inside(p.position, ctx.size());
self.pressed = inside_now;
self.state_layer.set_pressed(inside_now);
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.state_layer.set_pressed(false);
self.press_anim.fling(-FLING_VELOCITY, PRESS_SPRING);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.pressed = false;
self.captured = false;
self.state_layer.set_pressed(false);
self.press_anim.fling(-FLING_VELOCITY, PRESS_SPRING);
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::Button, |node| {
if let Some(label) = &self.label_text {
node.set_label(label.as_str());
}
node.add_action(Action::Click);
});
}
frust::authoring::visit_children!(icon, visible_label);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::FrameTime;
use frust_widgets::test_support::leaf_any;
use std::any::Any;
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: frust::authoring::PointerButton::Primary,
})
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
shadows: Vec<(Point, Size, f64, f64, Color)>,
transforms: Vec<Affine>,
transform_pops: u32,
}
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) {
self.shadows.push((o, s, radius, std_dev, color));
}
fn push_transform(&mut self, transform: Affine) {
self.transforms.push(transform);
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
}
fn build_icon_fab(size: FabSize) -> FabWidget {
let view = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(size);
let mut counter = 0u64;
View::<()>::build(&view, &mut BuildCtx::new(&mut counter))
}
#[test]
fn container_sizes_match_m3e_theme_table() {
assert_eq!(FabSize::Small.container(), 40.0);
assert_eq!(FabSize::Medium.container(), 56.0);
assert_eq!(FabSize::Large.container(), 96.0);
}
#[test]
fn icon_sizes_match_m3e_theme_table() {
assert_eq!(FabSize::Small.icon_size(), 24.0);
assert_eq!(FabSize::Medium.icon_size(), 24.0);
assert_eq!(FabSize::Large.icon_size(), 36.0);
}
#[test]
fn themed_radii_match_m3e_theme_table() {
let theme = crate::baseline();
assert_eq!(FabSize::Small.themed_radius(&theme.shape), 12.0);
assert_eq!(FabSize::Medium.themed_radius(&theme.shape), 16.0);
assert_eq!(FabSize::Large.themed_radius(&theme.shape), 28.0);
}
#[test]
fn color_mapping_matches_m3e_theme_table_themed() {
let theme = crate::baseline();
let s = theme.scheme();
assert_eq!(
resolve_colors(Some(&theme), FabColor::Primary),
(s.primary_container, s.on_primary_container)
);
assert_eq!(
resolve_colors(Some(&theme), FabColor::Secondary),
(s.secondary_container, s.on_secondary_container)
);
assert_eq!(
resolve_colors(Some(&theme), FabColor::Tertiary),
(s.tertiary_container, s.on_tertiary_container)
);
assert_eq!(
resolve_colors(Some(&theme), FabColor::Surface),
(s.surface_container_high, s.primary)
);
}
#[test]
fn color_mapping_matches_m3e_theme_table_unthemed() {
assert_eq!(
resolve_colors(None, FabColor::Primary),
(PRIMARY_CONTAINER, ON_PRIMARY_CONTAINER)
);
assert_eq!(
resolve_colors(None, FabColor::Secondary),
(SECONDARY_CONTAINER, ON_SECONDARY_CONTAINER)
);
assert_eq!(
resolve_colors(None, FabColor::Tertiary),
(TERTIARY_CONTAINER, ON_TERTIARY_CONTAINER)
);
assert_eq!(
resolve_colors(None, FabColor::Surface),
(SURFACE_CONTAINER_HIGH, SURFACE_FOREGROUND)
);
}
#[test]
fn press_spring_matches_reference() {
let spring = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
assert_eq!(PRESS_SPRING.stiffness, spring.stiffness);
assert_eq!(PRESS_SPRING.damping_ratio, spring.damping_ratio);
assert_eq!(PRESS_SPRING.mass, 1.0);
}
#[test]
fn pressed_scale_targets_match_m3e_theme_table() {
assert_eq!(FAB_PRESSED_SCALE, 0.95);
assert_eq!(EXTENDED_PRESSED_SCALE, 0.97);
}
#[test]
fn layout_is_a_fixed_square_at_the_container_size() {
let mut w = build_icon_fab(FabSize::Large);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert_eq!(size, Size::new(96.0, 96.0));
let icon = w.icon.as_ref().unwrap();
assert_eq!(icon.size(), Size::new(36.0, 36.0));
assert_eq!(icon.origin(), Point::new(30.0, 30.0));
}
#[test]
fn unthemed_paint_uses_fallback_container_and_shadow() {
let mut w = build_icon_fab(FabSize::Medium);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rrects[0].3, PRIMARY_CONTAINER);
assert_eq!(rec.rrects[0].2, MEDIUM_RADIUS);
assert_eq!(rec.shadows.len(), 1, "a resting FAB paints one shadow");
assert_eq!(rec.shadows[0].3, LEVEL3_SHADOW_BLUR);
assert_eq!(rec.shadows[0].4, FALLBACK_SHADOW_COLOR);
assert_eq!(
rec.shadows[0].0,
Point::new(0.0, LEVEL3_SHADOW_Y_OFFSET),
"the shadow origin is offset by y_offset"
);
assert_eq!(rec.transforms.len(), 1, "one press-scale transform pushed");
assert_eq!(rec.transform_pops, 1, "paired with one pop");
}
#[test]
fn themed_paint_resolves_primary_container_tokens() {
let theme = crate::baseline();
let mut w = build_icon_fab(FabSize::Small);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(40.0, 40.0)).with_theme(&theme);
w.paint(&mut pctx, &mut rec);
let scheme = theme.scheme();
assert_eq!(rec.rrects[0].3, scheme.primary_container);
assert_eq!(rec.rrects[0].2, theme.shape.medium);
assert_eq!(
rec.shadows[0].4.components[3],
theme.elevation.level3.shadow(theme.brightness).color_alpha
);
}
#[test]
fn extended_layout_has_fixed_height_and_grows_width() {
let view: FabView<()> =
extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
let mut counter = 0u64;
let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
w.label_anim.advance(FrameTime::ZERO);
w.label_anim.advance(ft_secs(1.0));
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert_eq!(size.height, EXTENDED_HEIGHT);
assert!(size.width > EXTENDED_ICON + EXTENDED_PAD_EXPANDED * 2.0);
}
#[test]
fn extended_label_collapse_animates_width_between_endpoints() {
let extended_view: FabView<()> =
extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
let mut counter = 0u64;
let mut w = View::<()>::build(&extended_view, &mut BuildCtx::new(&mut counter));
w.label_anim.advance(FrameTime::ZERO);
w.label_anim.advance(ft_secs(1.0)); assert_eq!(w.label_anim.value_clamped(), 1.0);
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let extended_size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
let collapsed_view: FabView<()> = extended_fab("Compose", |_s: &mut ()| {})
.icon(leaf_any(24.0, 24.0))
.extended(false);
let mut w2 = View::<()>::build(&collapsed_view, &mut BuildCtx::new(&mut counter));
assert_eq!(w2.label_anim.value_clamped(), 0.0);
let mut tcx2 = frust::authoring::text::TextContext::new();
let mut lctx2 = LayoutCtx::with_text_context(&mut tcx2 as &mut dyn Any);
let collapsed_size = w2.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert_eq!(extended_size.height, EXTENDED_HEIGHT);
assert_eq!(collapsed_size.height, EXTENDED_HEIGHT);
assert!(
collapsed_size.width < extended_size.width,
"a collapsed extended FAB is narrower than a fully open one"
);
assert_eq!(
collapsed_size.width,
EXTENDED_PAD_COLLAPSED * 2.0 + EXTENDED_ICON
);
}
#[test]
fn rebuild_extended_to_plain_tears_down_the_stale_label_pod() {
let extended_view: FabView<()> =
extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
let mut counter = 0u64;
let mut w = View::<()>::build(&extended_view, &mut BuildCtx::new(&mut counter));
assert!(
w.visible_label.is_some(),
"an extended FAB mounts a visible label pod"
);
let plain_view: FabView<()> = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {});
View::<()>::rebuild(
&plain_view,
&extended_view,
&mut w,
&mut BuildCtx::new(&mut counter),
);
assert!(
w.visible_label.is_none(),
"swapping to a plain FAB tears down the stale label pod"
);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert_eq!(
size,
Size::new(MEDIUM_CONTAINER, MEDIUM_CONTAINER),
"the collapsed FAB lays out as the plain square, not the old pill"
);
}
#[test]
fn rebuild_plain_to_extended_mounts_the_label_and_reveal_animates() {
let plain_view: FabView<()> = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {});
let mut counter = 0u64;
let mut w = View::<()>::build(&plain_view, &mut BuildCtx::new(&mut counter));
w.label_anim.advance(FrameTime::ZERO);
w.label_anim.advance(ft_secs(1.0));
assert_eq!(w.label_anim.value_clamped(), 1.0);
assert!(w.visible_label.is_none());
let extended_view: FabView<()> =
extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
View::<()>::rebuild(
&extended_view,
&plain_view,
&mut w,
&mut BuildCtx::new(&mut counter),
);
assert!(
w.visible_label.is_some(),
"swapping to an extended FAB mounts the label pod"
);
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let just_swapped_size =
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
w.label_anim.advance(FrameTime::ZERO);
w.label_anim.advance(ft_secs(1.0));
let mut tcx2 = frust::authoring::text::TextContext::new();
let mut lctx2 = LayoutCtx::with_text_context(&mut tcx2 as &mut dyn Any);
let grown_size = w.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert!(
grown_size.width > just_swapped_size.width,
"the label grows in across advanced frames: {just_swapped_size:?} -> {grown_size:?}"
);
}
#[test]
fn paint_requests_layout_while_label_reveal_is_in_flight_and_stops_once_settled() {
fn layout_at(w: &mut FabWidget) -> Size {
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)))
}
fn paint_at(w: &mut FabWidget, size: Size, t: f64) -> (Recorder, bool) {
let mut rec = Recorder::default();
let mut ctx = PaintCtx::for_test(Point::ZERO, size, ft_secs(t));
w.paint(&mut ctx, &mut rec);
let needs_layout = ctx.needs_layout();
(rec, needs_layout)
}
let collapsed: FabView<()> = extended_fab("Compose", |_s: &mut ()| {})
.icon(leaf_any(24.0, 24.0))
.extended(false);
let mut counter = 0u64;
let mut w = View::<()>::build(&collapsed, &mut BuildCtx::new(&mut counter));
let mut size = layout_at(&mut w);
let collapsed_width = size.width;
let expanded: FabView<()> =
extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
View::<()>::rebuild(
&expanded,
&collapsed,
&mut w,
&mut BuildCtx::new(&mut counter),
);
size = layout_at(&mut w);
assert_eq!(size.width, collapsed_width);
let mut widths = vec![size.width];
let mut frames = 0u32;
let mut t = 0.0;
loop {
let (_, needs_layout) = paint_at(&mut w, size, t);
if !needs_layout {
break;
}
frames += 1;
size = layout_at(&mut w);
widths.push(size.width);
t += 1.0 / 60.0;
assert!(
frames < 600,
"the reveal should settle well inside 600 frames"
);
}
assert!(frames > 1, "the reveal spans more than one frame");
assert!(
widths.windows(2).all(|pair| pair[1] + 1e-9 >= pair[0]),
"the pill's laid-out width never shrinks while opening: {widths:?}"
);
let last = *widths.last().expect("at least one frame recorded");
assert!(
last > collapsed_width,
"the settled pill is wider than the frozen collapsed square: {last} > {collapsed_width}"
);
let (_, still) = paint_at(&mut w, size, t + 1.0 / 60.0);
assert!(!still, "settled: no more layout requests");
}
#[test]
fn corner_radius_override_is_immediate_no_animation() {
let view: FabView<()> =
fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).corner_radius(Some(40.0));
let mut counter = 0u64;
let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rrects[0].2, 40.0, "paints the override radius exactly");
let view2: FabView<()> =
fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).corner_radius(Some(8.0));
let mut lctx2 = LayoutCtx::new();
View::<()>::rebuild(&view2, &view, &mut w, &mut BuildCtx::new(&mut counter));
w.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
let mut rec2 = Recorder::default();
let mut pctx2 = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
w.paint(&mut pctx2, &mut rec2);
assert_eq!(
rec2.rrects[0].2, 8.0,
"a changed override still snaps immediately, no ease from the prior value"
);
}
#[test]
fn corner_radius_none_eases_a_changed_target_with_short_4() {
let mut w = build_icon_fab(FabSize::Small);
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
let theme = crate::baseline();
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(40.0, 40.0)).with_theme(&theme);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rrects[0].2, 12.0);
let prev_view: FabView<()> =
fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(FabSize::Small);
let next_view: FabView<()> =
fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(FabSize::Large);
let mut counter = 1u64;
View::<()>::rebuild(
&next_view,
&prev_view,
&mut w,
&mut BuildCtx::new(&mut counter),
);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
let mut rec2 = Recorder::default();
let mut pctx2 = PaintCtx::new(Point::ZERO, Size::new(96.0, 96.0)).with_theme(&theme);
w.paint(&mut pctx2, &mut rec2);
assert_eq!(
rec2.rrects[0].2, 12.0,
"the change-triggering frame hasn't eased yet (first advance only seeds the clock)"
);
w.radius_anim.advance(ft_secs(1.0));
let mut rec3 = Recorder::default();
let mut pctx3 = PaintCtx::new(Point::ZERO, Size::new(96.0, 96.0)).with_theme(&theme);
w.paint(&mut pctx3, &mut rec3);
assert_eq!(
rec3.rrects[0].2, 28.0,
"settled at the new (Large) themed radius"
);
}
fn icon_stub<State: 'static>() -> AnyView<State> {
frust::authoring::any::<State, _>(frust::text("i"))
}
fn dispatch(w: &mut FabWidget, state: &mut u32, event: &InputEvent, size: Size) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
w.event(&mut ctx, event);
}
#[test]
fn up_inside_fires_on_press() {
let view: FabView<u32> =
fab(icon_stub::<u32>(), |s: &mut u32| *s += 1).size(FabSize::Medium);
let mut counter = 0u64;
let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
let mut state = 0u32;
let size = Size::new(56.0, 56.0);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 28.0, 28.0),
size,
);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 28.0, 28.0), size);
assert_eq!(state, 1);
}
#[test]
fn up_outside_does_not_fire() {
let view: FabView<u32> = fab(icon_stub::<u32>(), |s: &mut u32| *s += 1);
let mut counter = 0u64;
let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
let mut state = 0u32;
let size = Size::new(56.0, 56.0);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 10.0, 10.0),
size,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 500.0, 500.0),
size,
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, 500.0, 500.0),
size,
);
assert_eq!(state, 0);
}
#[test]
fn cancel_clears_armed_state_without_firing() {
let view: FabView<u32> = fab(icon_stub::<u32>(), |s: &mut u32| *s += 1);
let mut counter = 0u64;
let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
let mut state = 0u32;
let size = Size::new(56.0, 56.0);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 10.0, 10.0),
size,
);
assert!(w.captured);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Cancel, 10.0, 10.0),
size,
);
assert!(!w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0), size);
assert_eq!(state, 0);
}
#[test]
fn hover_move_outside_bounds_without_down_is_ignored_noop() {
let mut w = build_icon_fab(FabSize::Medium);
let mut state = 0u32;
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(56.0, 56.0));
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 500.0, 500.0));
assert!(matches!(result, EventResult::Ignored));
assert!(
!ctx.needs_redraw(),
"outside the bounds, nothing claims hover"
);
}
#[test]
fn hover_move_inside_bounds_without_down_claims_hover_and_redraws() {
let mut w = build_icon_fab(FabSize::Medium);
let mut state = 0u32;
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(56.0, 56.0));
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 10.0, 10.0));
assert!(matches!(result, EventResult::Ignored));
assert!(ctx.needs_redraw());
}
#[test]
fn down_starts_the_press_scale_spring_toward_pressed_and_up_reverses_it() {
let view: FabView<u32> = fab(icon_stub::<u32>(), |_s: &mut u32| {}).size(FabSize::Medium);
let mut counter = 0u64;
let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
let mut state = 0u32;
let size = Size::new(56.0, 56.0);
assert!(!w.press_anim.is_animating());
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, 28.0, 28.0),
size,
);
assert!(
w.press_anim.is_animating(),
"Down starts the fling toward pressed"
);
let mut t = 0.0;
let mut running = true;
for _ in 0..100_000 {
running = w.press_anim.advance(ft_secs(t));
if !running {
break;
}
t += 1.0 / 120.0;
}
assert!(!running, "press-in fling failed to settle");
let pressed_scale = Tween::new(1.0, FAB_PRESSED_SCALE).lerp(w.press_anim.value());
assert!((pressed_scale - FAB_PRESSED_SCALE).abs() < 1e-6);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 28.0, 28.0), size);
assert!(
w.press_anim.is_animating(),
"Up starts the fling back to rest"
);
t = 0.0;
running = true;
for _ in 0..100_000 {
running = w.press_anim.advance(ft_secs(t));
if !running {
break;
}
t += 1.0 / 120.0;
}
assert!(!running, "press-out fling failed to settle");
let rest_scale = Tween::new(1.0, FAB_PRESSED_SCALE).lerp(w.press_anim.value());
assert!((rest_scale - 1.0).abs() < 1e-6);
}
#[test]
fn semantics_reports_button_role_and_label() {
fn logic(_s: &mut ()) -> FabView<()> {
fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).label("Compose")
}
let mut root: frust_core::RenderRoot<(), FabView<()>> = frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(200.0, 200.0));
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Button)
.expect("fab contributes a Role::Button node");
assert_eq!(node.label(), Some("Compose"));
assert!(node.supports_action(Action::Click));
}
#[test]
fn extended_semantics_uses_visible_label_as_accessible_name() {
fn logic(_s: &mut ()) -> FabView<()> {
extended_fab::<(), _>("New item", |_s: &mut ()| {})
}
let mut root: frust_core::RenderRoot<(), FabView<()>> = 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(300.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("extended fab contributes a Role::Button node");
assert_eq!(node.label(), Some("New item"));
}
#[test]
fn hover_lifts_elevation_from_level3_to_level4() {
fn logic(_s: &mut ()) -> FabView<()> {
fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {})
}
let mut root: frust_core::RenderRoot<(), FabView<()>> = frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(200.0, 200.0));
let mut rec = Recorder::default();
root.paint(&mut rec, frust::FrameTime::ZERO);
assert_eq!(
rec.shadows[0].3, LEVEL3_SHADOW_BLUR,
"resting FAB is at level 3"
);
root.event(
&mut state,
&InputEvent::Pointer(frust::authoring::PointerEvent {
phase: PointerPhase::Move,
position: Point::new(28.0, 28.0),
button: frust::authoring::PointerButton::Primary,
}),
);
let mut rec2 = Recorder::default();
root.paint(&mut rec2, frust::FrameTime::ZERO);
assert_eq!(
rec2.shadows[0].3, LEVEL4_SHADOW_BLUR,
"a hovered FAB lifts to level 4"
);
}
}