use std::sync::OnceLock;
use std::time::Duration;
use frust::authoring::{Action, Role};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget,
};
use frust::{AnimationController, FrameTime, SpringDesc, Theme};
use kurbo::{Affine, BezPath, Point, Rect, Size};
use peniko::{Brush, Color};
use frust::authoring::{ThemeTextColor, ThemeTextType};
use super::press::presses;
use crate::shapes::{Morph, ShapeKind};
use crate::tokens::MaterialSpring;
const MAIN_CONTAINER_CLOSED: f64 = 80.0;
const MAIN_CONTAINER_OPEN: f64 = 56.0;
const MAIN_ICON: f64 = 24.0;
const EDGE_MARGIN: f64 = 16.0;
const ITEM_HEIGHT: f64 = 56.0;
const ITEM_ICON: f64 = 24.0;
const ITEM_PAD_X: f64 = 20.0;
const ITEM_ICON_LABEL_GAP: f64 = 12.0;
const ITEM_GAP: f64 = 12.0;
const FAB_TO_ITEMS_GAP: f64 = 12.0;
const MAIN_CONTAINER_COLOR: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
const PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
const TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
const SCRIM_ALPHA: f32 = 0.0;
const SCRIM_FALLBACK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
const FALLBACK_SHADOW_Y_OFFSET: f64 = 4.0;
const FALLBACK_SHADOW_BLUR: f64 = 6.0;
const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
const TRIGGER_CLOSED_SHAPE: ShapeKind = ShapeKind::Square;
const TRIGGER_OPEN_SHAPE: ShapeKind = ShapeKind::Circle;
const SHADOW_RADIUS_FRACTION_CLOSED: f64 = 16.0 / 56.0;
const FAB_SHAPE_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
damping_ratio: 0.7,
};
const ITEM_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.stiffness,
damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.damping_ratio,
};
const EXPAND_STAGGER_MS: u64 = 30;
const ANIM_PERIOD: Duration = Duration::from_millis(300);
const FLING_VELOCITY: f64 = 3.0;
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
fn icon_size_for(container: f64) -> f64 {
MAIN_ICON * (container / MAIN_CONTAINER_OPEN)
}
fn trigger_shadow_radius(container: f64, t: f64) -> f64 {
let closed = container * SHADOW_RADIUS_FRACTION_CLOSED;
let open = container / 2.0;
lerp(closed, open, t.clamp(0.0, 1.0))
}
fn trigger_morph() -> &'static Morph {
static CACHE: OnceLock<Morph> = OnceLock::new();
CACHE.get_or_init(|| {
Morph::new(
TRIGGER_CLOSED_SHAPE.polygon().clone(),
TRIGGER_OPEN_SHAPE.polygon().clone(),
)
})
}
fn trigger_path(t: f64, rect: Rect) -> BezPath {
let raw = trigger_morph().path(t.clamp(0.0, 1.0));
let scale = Affine::new([rect.width(), 0.0, 0.0, rect.height(), 0.0, 0.0]);
Affine::translate(rect.origin().to_vec2()) * (scale * raw)
}
fn cascade_delay(i: usize, count: usize, opening: bool) -> Duration {
let step = if opening {
i
} else {
count.saturating_sub(1).saturating_sub(i)
};
Duration::from_millis(step as u64 * EXPAND_STAGGER_MS)
}
fn snap_anim(anim: &mut AnimationController, open: bool, now: FrameTime) -> bool {
let target = if open { 1.0 } else { 0.0 };
let moved = anim.value() != target;
*anim = AnimationController::new(Duration::ZERO);
if open {
anim.forward();
} else {
anim.reverse();
}
anim.advance(now);
moved
}
fn resolve_main_color(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().primary_container,
None => MAIN_CONTAINER_COLOR,
}
}
fn resolve_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
match theme {
Some(theme) => {
let level = 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 => (
FALLBACK_SHADOW_BLUR,
FALLBACK_SHADOW_Y_OFFSET,
FALLBACK_SHADOW_COLOR,
),
}
}
fn resolve_scrim_base(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().scrim,
None => SCRIM_FALLBACK,
}
}
fn resolve_item_color(theme: Option<&Theme>, index: usize) -> Color {
match theme {
Some(theme) => {
let s = theme.scheme();
match index % 3 {
0 => s.primary_container,
1 => s.secondary_container,
_ => s.tertiary_container,
}
}
None => match index % 3 {
0 => PRIMARY_CONTAINER,
1 => SECONDARY_CONTAINER,
_ => TERTIARY_CONTAINER,
},
}
}
fn item_radius() -> f64 {
ITEM_HEIGHT / 2.0
}
fn label_view<State: 'static>(label: String) -> AnyView<State> {
frust::authoring::any::<State, _>(
frust::text(label)
.themed_role(ThemeTextColor::OnSurface)
.themed_family(ThemeTextType::TitleMedium),
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Target {
Fab,
Item(usize),
Scrim,
}
pub struct FabMenuItem<State: 'static> {
icon: AnyView<State>,
label: String,
on_select: std::rc::Rc<dyn Fn(&mut State)>,
}
pub fn fab_menu_item<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
label: impl Into<String>,
on_select: F,
) -> FabMenuItem<State> {
FabMenuItem {
icon,
label: label.into(),
on_select: std::rc::Rc::new(on_select),
}
}
pub struct FabMenuView<State: 'static> {
icon: AnyView<State>,
open: bool,
items: Vec<FabMenuItem<State>>,
label: Option<String>,
on_toggle: std::rc::Rc<dyn Fn(&mut State)>,
}
pub fn fab_menu<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
open: bool,
items: Vec<FabMenuItem<State>>,
on_toggle: F,
) -> FabMenuView<State> {
FabMenuView {
icon,
open,
items,
label: None,
on_toggle: std::rc::Rc::new(on_toggle),
}
}
#[allow(non_snake_case)]
pub fn FabMenu<State: 'static, F: Fn(&mut State) + 'static>(
icon: AnyView<State>,
open: bool,
items: Vec<FabMenuItem<State>>,
on_toggle: F,
) -> FabMenuView<State> {
fab_menu(icon, open, items, on_toggle)
}
impl<State: 'static> FabMenuView<State> {
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
struct FabMenuItemPod {
icon: ChildPod,
label: ChildPod,
on_select: frust::authoring::ErasedCallback,
}
impl frust::authoring::VisitPods for FabMenuItemPod {
fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
visitor(&self.icon);
visitor(&self.label);
}
}
pub struct FabMenuWidget {
icon: ChildPod,
items: Vec<FabMenuItemPod>,
item_labels: Vec<String>,
open: bool,
label: Option<String>,
fab_morph: AnimationController,
item_anim: Vec<AnimationController>,
cascade_launched: Vec<bool>,
cascade_epoch: Option<FrameTime>,
fab_rect: Rect,
item_rects: Vec<Rect>,
armed: Option<Target>,
pressed_inside: bool,
on_toggle: frust::authoring::ErasedCallback,
}
impl FabMenuWidget {
fn drive_reveal(&mut self) {
let velocity = if self.open {
FLING_VELOCITY
} else {
-FLING_VELOCITY
};
self.fab_morph.fling(velocity, FAB_SHAPE_SPRING);
self.cascade_launched = vec![false; self.item_anim.len()];
self.cascade_epoch = None;
}
fn advance_cascade(&mut self, now: FrameTime) -> bool {
let epoch = *self.cascade_epoch.get_or_insert(now);
let elapsed = now.saturating_sub(epoch);
let n = self.item_anim.len();
let mut moved = false;
for i in 0..n {
if !self.cascade_launched[i] && elapsed >= cascade_delay(i, n, self.open) {
let velocity = if self.open {
FLING_VELOCITY
} else {
-FLING_VELOCITY
};
self.item_anim[i].fling(velocity, ITEM_SPRING);
self.cascade_launched[i] = true;
}
moved |= self.item_anim[i].advance(now);
if !self.open && self.item_anim[i].is_animating() && self.item_anim[i].value() <= 0.0 {
snap_anim(&mut self.item_anim[i], false, now);
}
}
moved
}
}
impl<State: 'static> View<State> for FabMenuView<State> {
type Element = FabMenuWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> FabMenuWidget {
let items = self
.items
.iter()
.map(|item| FabMenuItemPod {
icon: frust::authoring::build_child(&item.icon, ctx),
label: frust::authoring::build_child(&label_view::<State>(item.label.clone()), ctx),
on_select: frust::authoring::erase_callback(&item.on_select),
})
.collect();
let n = self.items.len();
let mut fab_morph = AnimationController::new(ANIM_PERIOD);
let mut item_anim: Vec<AnimationController> = (0..n)
.map(|_| AnimationController::new(ANIM_PERIOD))
.collect();
snap_anim(&mut fab_morph, self.open, FrameTime::ZERO);
for a in item_anim.iter_mut() {
snap_anim(a, self.open, FrameTime::ZERO);
}
let cascade_launched = vec![true; n];
FabMenuWidget {
icon: frust::authoring::build_child(&self.icon, ctx),
items,
item_labels: self.items.iter().map(|i| i.label.clone()).collect(),
open: self.open,
label: self.label.clone(),
fab_morph,
item_anim,
cascade_launched,
cascade_epoch: None,
fab_rect: Rect::ZERO,
item_rects: Vec::new(),
armed: None,
pressed_inside: false,
on_toggle: frust::authoring::erase_callback(&self.on_toggle),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut FabMenuWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_toggle = frust::authoring::erase_callback(&self.on_toggle);
let mut flags = ChangeFlags::NONE;
flags |= frust::authoring::rebuild_child(&prev.icon, &self.icon, &mut element.icon, ctx);
if prev.open != self.open {
element.open = self.open;
element.drive_reveal();
if !self.open {
element.armed = None;
element.pressed_inside = false;
}
flags |= ChangeFlags::PAINT;
}
if prev.label != self.label {
element.label = self.label.clone();
}
if prev.items.len() != self.items.len() {
for (old_item, pod) in prev.items.iter().zip(element.items.iter_mut()) {
frust::authoring::teardown_child(&old_item.icon, &mut pod.icon, ctx);
let old_label_view = label_view::<State>(old_item.label.clone());
frust::authoring::teardown_child(&old_label_view, &mut pod.label, ctx);
}
element.items = self
.items
.iter()
.map(|item| FabMenuItemPod {
icon: frust::authoring::build_child(&item.icon, ctx),
label: frust::authoring::build_child(
&label_view::<State>(item.label.clone()),
ctx,
),
on_select: frust::authoring::erase_callback(&item.on_select),
})
.collect();
element.item_labels = self.items.iter().map(|i| i.label.clone()).collect();
element.armed = None;
element.pressed_inside = false;
let n = self.items.len();
element.item_anim = (0..n)
.map(|_| AnimationController::new(ANIM_PERIOD))
.collect();
element.cascade_epoch = None;
for a in element.item_anim.iter_mut() {
snap_anim(a, element.open, FrameTime::ZERO);
}
element.cascade_launched = vec![true; n];
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else {
for (i, (prev_item, next_item)) in prev.items.iter().zip(self.items.iter()).enumerate()
{
let pod = &mut element.items[i];
flags |= frust::authoring::rebuild_child(
&prev_item.icon,
&next_item.icon,
&mut pod.icon,
ctx,
);
if prev_item.label != next_item.label {
let prev_view = label_view::<State>(prev_item.label.clone());
let next_view = label_view::<State>(next_item.label.clone());
flags |= frust::authoring::rebuild_child(
&prev_view,
&next_view,
&mut pod.label,
ctx,
);
element.item_labels[i] = next_item.label.clone();
}
pod.on_select = frust::authoring::erase_callback(&next_item.on_select);
}
}
flags
}
fn teardown(&self, element: &mut FabMenuWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(&self.icon, &mut element.icon, ctx);
for (item, pod) in self.items.iter().zip(element.items.iter_mut()) {
frust::authoring::teardown_child(&item.icon, &mut pod.icon, ctx);
let label_view = label_view::<State>(item.label.clone());
frust::authoring::teardown_child(&label_view, &mut pod.label, ctx);
}
}
}
impl Widget for FabMenuWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let area_w = finite_or_zero(bc.max().width);
let area_h = finite_or_zero(bc.max().height);
let fab_t = self.fab_morph.value_clamped();
let container = lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, fab_t);
let icon_size = icon_size_for(container);
let icon_layout_size = self
.icon
.layout_child(ctx, &BoxConstraints::tight(Size::new(icon_size, icon_size)));
let fab_origin = Point::new(
(area_w - EDGE_MARGIN - container).max(0.0),
(area_h - EDGE_MARGIN - container).max(0.0),
);
self.fab_rect = Rect::from_origin_size(fab_origin, Size::new(container, container));
self.icon.set_origin(Point::new(
fab_origin.x + (container - icon_layout_size.width) / 2.0,
fab_origin.y + (container - icon_layout_size.height) / 2.0,
));
self.item_rects.clear();
let right_edge = self.fab_rect.x1;
let mut bottom = self.fab_rect.y0 - FAB_TO_ITEMS_GAP;
let radius = item_radius();
for pod in self.items.iter_mut() {
let icon_size = pod
.icon
.layout_child(ctx, &BoxConstraints::tight(Size::new(ITEM_ICON, ITEM_ICON)));
let label_size = pod.label.layout_child(
ctx,
&BoxConstraints::loose(Size::new(f64::INFINITY, ITEM_HEIGHT)),
);
let content_w = icon_size.width + ITEM_ICON_LABEL_GAP + label_size.width;
let item_w = (content_w + ITEM_PAD_X * 2.0).max(radius * 2.0);
let top = bottom - ITEM_HEIGHT;
let rect = Rect::new((right_edge - item_w).max(0.0), top, right_edge, bottom);
pod.icon.set_origin(Point::new(
rect.x0 + ITEM_PAD_X,
rect.y0 + (ITEM_HEIGHT - icon_size.height) / 2.0,
));
pod.label.set_origin(Point::new(
rect.x0 + ITEM_PAD_X + icon_size.width + ITEM_ICON_LABEL_GAP,
rect.y0 + (ITEM_HEIGHT - label_size.height) / 2.0,
));
self.item_rects.push(rect);
bottom = top - ITEM_GAP;
}
bc.constrain(Size::new(area_w, area_h))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let scrim_base = resolve_scrim_base(theme);
let item_colors: Vec<Color> = (0..self.items.len())
.map(|i| resolve_item_color(theme, i))
.collect();
let main_color = resolve_main_color(theme);
let (blur, y_offset, shadow_color) = resolve_shadow(theme);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
let now = ctx.frame_time();
let fab_moved = if reduce_motion {
snap_anim(&mut self.fab_morph, self.open, now)
} else {
let before = self.fab_morph.value();
let animating = self.fab_morph.advance(now);
animating || self.fab_morph.value() != before
};
if fab_moved {
ctx.request_layout();
}
let n = self.item_anim.len();
if reduce_motion {
for a in self.item_anim.iter_mut() {
snap_anim(a, self.open, now);
}
self.cascade_launched = vec![true; n];
} else {
self.advance_cascade(now);
}
let origin = ctx.origin();
if self.open {
let scrim = with_alpha(scrim_base, SCRIM_ALPHA);
scene.fill_rect(origin, ctx.size(), scrim);
}
let item_radius = item_radius();
for (i, (pod, rect)) in self
.items
.iter_mut()
.zip(self.item_rects.iter())
.enumerate()
{
let t = self.item_anim[i].value_clamped();
if t <= 0.0 {
continue;
}
let item_origin = Point::new(origin.x + rect.x0, origin.y + rect.y0);
scene.push_layer(item_origin, rect.size(), t as f32);
scene.fill_rounded_rect(item_origin, rect.size(), item_radius, item_colors[i]);
pod.icon.paint_child(ctx, scene);
pod.label.paint_child(ctx, scene);
scene.pop_layer();
}
let fab_t = self.fab_morph.value_clamped();
let shadow_radius = trigger_shadow_radius(self.fab_rect.width(), fab_t);
scene.draw_shadow(
Point::new(
origin.x + self.fab_rect.x0,
origin.y + self.fab_rect.y0 + y_offset,
),
self.fab_rect.size(),
shadow_radius,
blur,
shadow_color,
);
let path = trigger_path(fab_t, self.fab_rect);
scene.fill_path(origin, &path, &Brush::Solid(main_color));
self.icon.paint_child(ctx, scene);
if !reduce_motion {
let still_animating = self.fab_morph.is_animating()
|| self.item_anim.iter().any(AnimationController::is_animating)
|| self.cascade_launched.iter().any(|launched| !launched);
if still_animating {
ctx.request_frame();
}
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Key(key_event) = event {
if self.open && key_event.key == Key::Named(NamedKey::Escape) {
(self.on_toggle)(ctx);
ctx.request_redraw();
return EventResult::Handled;
}
return EventResult::Ignored;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if self.fab_rect.contains(p.position) {
ctx.request_focus();
if !presses(p) {
return if self.open {
EventResult::Handled
} else {
EventResult::Ignored
};
}
self.armed = Some(Target::Fab);
self.pressed_inside = true;
ctx.capture_pointer();
ctx.request_redraw();
return EventResult::Handled;
}
if !self.open {
return EventResult::Ignored;
}
ctx.request_focus();
if !presses(p) {
return EventResult::Handled;
}
if let Some(i) = self.item_rects.iter().position(|r| r.contains(p.position)) {
self.armed = Some(Target::Item(i));
} else {
self.armed = Some(Target::Scrim);
}
self.pressed_inside = true;
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
let Some(target) = self.armed else {
return EventResult::Ignored;
};
self.pressed_inside = match target {
Target::Fab => self.fab_rect.contains(p.position),
Target::Item(i) => self
.item_rects
.get(i)
.is_some_and(|r| r.contains(p.position)),
Target::Scrim => true,
};
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Up => {
let Some(target) = self.armed.take() else {
return EventResult::Ignored;
};
if self.pressed_inside {
match target {
Target::Fab => (self.on_toggle)(ctx),
Target::Item(i) => {
if let Some(pod) = self.items.get_mut(i) {
(pod.on_select)(ctx);
}
(self.on_toggle)(ctx);
}
Target::Scrim => (self.on_toggle)(ctx),
}
}
self.pressed_inside = false;
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if self.armed.is_none() {
return EventResult::Ignored;
}
self.armed = None;
self.pressed_inside = false;
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let trigger_label = self.label.as_deref().unwrap_or("Menu");
if self.open {
ctx.push_container(
Role::Group,
|_| {},
|ctx| {
ctx.push_node(Role::Button, |node| {
node.set_label(trigger_label);
node.set_expanded(true);
node.add_action(Action::Click);
node.add_action(Action::Expand);
});
ctx.push_container(
Role::Menu,
|_| {},
|ctx| {
for label in &self.item_labels {
ctx.push_node(Role::MenuItem, |node| {
node.set_label(label.as_str());
node.add_action(Action::Click);
});
}
},
);
},
);
} else {
ctx.push_node(Role::Button, |node| {
node.set_label(trigger_label);
node.set_expanded(false);
node.add_action(Action::Click);
node.add_action(Action::Expand);
});
}
}
frust::authoring::visit_children!(items, icon);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::{KeyEvent, Modifiers, PointerButton, PointerEvent};
use frust_core::RenderRoot;
use frust_widgets::test_support::leaf_any;
use kurbo::{PathEl, Shape};
use std::any::Any;
fn icon_stub<State: 'static>() -> AnyView<State> {
frust::authoring::any::<State, _>(frust::text("i"))
}
#[derive(Default)]
struct Log {
toggles: u32,
selected: Vec<usize>,
}
fn item(i: usize) -> FabMenuItem<Log> {
fab_menu_item(
icon_stub::<Log>(),
format!("Item {i}"),
move |s: &mut Log| s.selected.push(i),
)
}
fn build_menu(open: bool, item_count: usize) -> FabMenuWidget {
let view = fab_menu::<Log, _>(
icon_stub::<Log>(),
open,
(0..item_count).map(item).collect(),
|s: &mut Log| s.toggles += 1,
);
let mut counter = 0u64;
View::<Log>::build(&view, &mut BuildCtx::new(&mut counter))
}
fn open_fully(w: &mut FabMenuWidget) {
snap_anim(&mut w.fab_morph, true, FrameTime::ZERO);
for a in w.item_anim.iter_mut() {
snap_anim(a, true, FrameTime::ZERO);
}
w.cascade_launched = vec![true; w.item_anim.len()];
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn dispatch(w: &mut FabMenuWidget, state: &mut Log, event: &InputEvent) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, event)
}
fn laid_out(w: &mut FabMenuWidget) {
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::tight(Size::new(400.0, 600.0)));
}
#[test]
fn trigger_shape_endpoints_are_a_rounded_square_and_a_circle() {
assert_eq!(TRIGGER_CLOSED_SHAPE, ShapeKind::Square);
assert_eq!(TRIGGER_OPEN_SHAPE, ShapeKind::Circle);
let morph = trigger_morph();
assert_eq!(*morph.start(), *ShapeKind::Square.polygon());
assert_eq!(*morph.end(), *ShapeKind::Circle.polygon());
}
#[test]
fn trigger_path_is_closed_at_every_progress_and_stays_near_its_box() {
let rect = Rect::from_origin_size(Point::new(10.0, 20.0), Size::new(56.0, 56.0));
for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
let path = trigger_path(t, rect);
assert!(
matches!(path.elements().last(), Some(PathEl::ClosePath)),
"t={t}"
);
let bounds = path.bounding_box();
assert!(
bounds.x0 >= rect.x0 - 1.0 && bounds.y0 >= rect.y0 - 1.0,
"t={t} {bounds:?}"
);
assert!(
bounds.x1 <= rect.x1 + 1.0 && bounds.y1 <= rect.y1 + 1.0,
"t={t} {bounds:?}"
);
}
}
#[test]
fn fab_shape_spring_matches_upstream_fab_shape_motion() {
assert_eq!(
FAB_SHAPE_SPRING.stiffness,
MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness
);
assert_eq!(FAB_SHAPE_SPRING.damping_ratio, 0.7);
assert_eq!(FAB_SHAPE_SPRING.mass, 1.0);
}
#[test]
fn trigger_container_lerps_between_the_named_endpoints() {
assert_eq!(MAIN_CONTAINER_CLOSED, 80.0);
assert_eq!(MAIN_CONTAINER_OPEN, 56.0);
assert_eq!(lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, 0.0), 80.0);
assert_eq!(lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, 1.0), 56.0);
assert_eq!(icon_size_for(MAIN_CONTAINER_OPEN), MAIN_ICON);
assert!(icon_size_for(MAIN_CONTAINER_CLOSED) > MAIN_ICON);
}
#[test]
fn trigger_morph_is_a_layout_value_and_requests_relayout_while_in_flight() {
let mut w = build_menu(false, 0);
laid_out(&mut w);
let closed_size = w.fab_rect.width();
assert_eq!(closed_size, MAIN_CONTAINER_CLOSED, "built closed: 80dp");
let prev = fab_menu::<Log, _>(icon_stub(), false, vec![], |_s: &mut Log| {});
let next = fab_menu::<Log, _>(icon_stub(), true, vec![], |_s: &mut Log| {});
let mut counter = 0u64;
View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
let area = Size::new(400.0, 600.0);
let mut rec = RectRecorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, area, FrameTime::from_nanos(0));
w.paint(&mut pctx, &mut rec);
let mut pctx = PaintCtx::for_test(Point::ZERO, area, FrameTime::from_nanos(80_000_000));
w.paint(&mut pctx, &mut rec);
assert!(
pctx.needs_layout(),
"an in-flight morph must request a relayout, not merely a repaint"
);
laid_out(&mut w);
assert!(
w.fab_rect.width() < closed_size,
"relaying out after the spring moved shrinks the trigger toward 56dp"
);
}
#[test]
fn cascade_stagger_step_matches_upstream() {
assert_eq!(EXPAND_STAGGER_MS, 30); }
#[test]
fn item_spring_matches_expressive_spatial_press() {
assert_eq!(ITEM_SPRING.stiffness, 380.0);
assert_eq!(ITEM_SPRING.damping_ratio, 0.55);
assert_eq!(
ITEM_SPRING.stiffness,
MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.stiffness
);
assert_eq!(
ITEM_SPRING.damping_ratio,
MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.damping_ratio
);
}
#[test]
fn opening_cascade_fires_nearest_the_fab_first() {
assert_eq!(cascade_delay(0, 4, true), Duration::ZERO);
assert_eq!(cascade_delay(1, 4, true), Duration::from_millis(30));
assert_eq!(cascade_delay(2, 4, true), Duration::from_millis(60));
assert_eq!(cascade_delay(3, 4, true), Duration::from_millis(90));
}
#[test]
fn closing_cascade_reverses_the_stagger_order() {
assert_eq!(cascade_delay(3, 4, false), Duration::ZERO);
assert_eq!(cascade_delay(2, 4, false), Duration::from_millis(30));
assert_eq!(cascade_delay(1, 4, false), Duration::from_millis(60));
assert_eq!(cascade_delay(0, 4, false), Duration::from_millis(90));
}
#[test]
fn advance_cascade_launches_items_in_order_as_time_elapses() {
let mut w = build_menu(false, 3);
laid_out(&mut w);
let prev = fab_menu::<Log, _>(
icon_stub(),
false,
(0..3).map(item).collect(),
|_s: &mut Log| {},
);
let next = fab_menu::<Log, _>(
icon_stub(),
true,
(0..3).map(item).collect(),
|_s: &mut Log| {},
);
let mut counter = 0u64;
View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(w.cascade_launched.iter().all(|&l| !l));
w.advance_cascade(FrameTime::ZERO);
assert!(
w.cascade_launched[0],
"index 0 (nearest the FAB) has a 0ms delay"
);
assert!(!w.cascade_launched[1]);
assert!(!w.cascade_launched[2]);
w.advance_cascade(FrameTime::from_nanos(35_000_000)); assert!(w.cascade_launched[1], "30ms delay elapsed");
assert!(!w.cascade_launched[2], "60ms delay hasn't elapsed yet");
w.advance_cascade(FrameTime::from_nanos(65_000_000)); assert!(w.cascade_launched[2], "60ms delay elapsed");
}
#[test]
fn item_count_change_rebuilds_pods_and_clears_armed() {
let mut w = build_menu(true, 1);
let prev = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |_s: &mut Log| {});
let next = fab_menu::<Log, _>(
icon_stub::<Log>(),
true,
vec![item(0), item(1), item(2)],
|_s: &mut Log| {},
);
let mut counter = 0u64;
View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.items.len(), 3);
assert_eq!(w.item_labels, vec!["Item 0", "Item 1", "Item 2"]);
assert_eq!(w.item_anim.len(), 3);
assert_eq!(
w.cascade_launched,
vec![true, true, true],
"already open: settles instantly"
);
}
#[test]
fn tapping_the_closed_trigger_requests_open() {
let mut w = build_menu(false, 2);
laid_out(&mut w);
let mut state = Log::default();
let fab_center = w.fab_rect.center();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, fab_center.x, fab_center.y),
);
assert_eq!(w.armed, Some(Target::Fab));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, fab_center.x, fab_center.y),
);
assert_eq!(state.toggles, 1, "trigger tap requests an open/close flip");
assert!(state.selected.is_empty());
}
#[test]
fn tapping_the_open_trigger_again_also_requests_a_flip() {
let mut w = build_menu(true, 2);
laid_out(&mut w);
let mut state = Log::default();
let fab_center = w.fab_rect.center();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, fab_center.x, fab_center.y),
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, fab_center.x, fab_center.y),
);
assert_eq!(state.toggles, 1, "tap-again closes");
}
#[test]
fn item_tap_fires_its_own_callback_then_closes() {
let mut w = build_menu(true, 2);
laid_out(&mut w);
assert_eq!(w.item_rects.len(), 2);
let mut state = Log::default();
let target = w.item_rects[0].center();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, target.x, target.y),
);
assert_eq!(w.armed, Some(Target::Item(0)));
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, target.x, target.y),
);
assert_eq!(state.selected, vec![0], "the tapped item's on_select fired");
assert_eq!(state.toggles, 1, "an item tap also requests a close");
}
#[test]
fn scrim_tap_closes_without_selecting_an_item() {
let mut w = build_menu(true, 1);
laid_out(&mut w);
let mut state = Log::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
assert_eq!(w.armed, Some(Target::Scrim));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.toggles, 1, "a scrim tap requests a close");
assert!(state.selected.is_empty());
}
#[test]
fn down_outside_the_trigger_is_ignored_while_closed() {
let mut w = build_menu(false, 1);
laid_out(&mut w);
let mut state = Log::default();
let result = dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
assert!(matches!(result, EventResult::Ignored));
assert!(w.armed.is_none());
}
#[test]
fn cancel_disarms_without_firing() {
let mut w = build_menu(true, 1);
laid_out(&mut w);
let mut state = Log::default();
let target = w.item_rects[0].center();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, target.x, target.y),
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Cancel, target.x, target.y),
);
assert!(w.armed.is_none());
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, target.x, target.y),
);
assert_eq!(state.toggles, 0);
assert!(state.selected.is_empty());
}
#[test]
fn release_outside_the_armed_item_fires_nothing() {
let mut w = build_menu(true, 2);
laid_out(&mut w);
let mut state = Log::default();
let item0 = w.item_rects[0].center();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, item0.x, item0.y),
);
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 500.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 500.0));
assert!(state.selected.is_empty());
assert_eq!(state.toggles, 0);
}
fn escape_event() -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
})
}
#[test]
fn escape_while_open_requests_toggle() {
let mut w = build_menu(true, 2);
laid_out(&mut w);
let mut state = Log::default();
let result = dispatch(&mut w, &mut state, &escape_event());
assert!(matches!(result, EventResult::Handled));
assert_eq!(state.toggles, 1, "Escape while open requests a close");
assert!(state.selected.is_empty());
}
#[test]
fn escape_while_closed_is_ignored() {
let mut w = build_menu(false, 2);
laid_out(&mut w);
let mut state = Log::default();
let result = dispatch(&mut w, &mut state, &escape_event());
assert!(matches!(result, EventResult::Ignored));
assert_eq!(state.toggles, 0, "a closed menu has nothing to dismiss");
}
#[test]
fn trigger_press_claims_focus_so_a_later_escape_reaches_the_open_menu() {
let mut root: RenderRoot<Log, FabMenuView<Log>> = RenderRoot::new();
let mut app = |_s: &mut Log| {
fab_menu::<Log, _>(
icon_stub::<Log>(),
true,
(0..2).map(item).collect(),
|s: &mut Log| s.toggles += 1,
)
};
let mut state = Log::default();
root.rebuild(&mut app, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
root.event(&mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
assert_eq!(
state.toggles, 0,
"the cancelled scrim press alone never toggles"
);
root.event(&mut state, &escape_event());
assert_eq!(
state.toggles, 1,
"Escape reaches the now-focused, still-open menu and requests a close"
);
}
#[test]
fn a_down_reclaims_trigger_focus_after_an_external_blur() {
use frust::authoring::ChildPod;
let mut w = build_menu(false, 2);
laid_out(&mut w);
let fab_center = w.fab_rect.center();
let mut pod = ChildPod::new(Box::new(w));
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(400.0, 600.0);
pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
let mut state = Log::default();
{
let s: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(
&mut ctx,
&ev(PointerPhase::Down, fab_center.x, fab_center.y),
);
}
assert!(pod.is_focused(), "the first trigger Down claims focus");
{
let s: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Up, fab_center.x, fab_center.y));
}
pod.set_focused(false);
{
let s: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(
&mut ctx,
&ev(PointerPhase::Down, fab_center.x, fab_center.y),
);
}
assert!(
pod.is_focused(),
"a second trigger Down must re-claim focus after an external blur — this \
is what keeps the root's focus/IME session alive while the menu is up"
);
}
#[test]
fn a_down_reclaims_scrim_focus_after_an_external_blur() {
use frust::authoring::ChildPod;
let w = build_menu(true, 2);
let mut pod = ChildPod::new(Box::new(w));
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(400.0, 600.0);
pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
let mut state = Log::default();
{
let s: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
}
assert!(pod.is_focused(), "the first scrim Down claims focus");
{
let s: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Cancel, 5.0, 5.0));
}
pod.set_focused(false);
{
let s: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
}
assert!(
pod.is_focused(),
"a second scrim/item Down must re-claim focus after an external blur — \
this is what keeps the root's focus/IME session alive while the menu is up"
);
}
#[test]
fn reopening_after_a_close_reclaims_focus() {
use frust::authoring::ChildPod;
let mut w = build_menu(false, 1);
laid_out(&mut w);
let fab_center = w.fab_rect.center();
let mut pod = ChildPod::new(Box::new(w));
let mut tcx = frust::authoring::text::TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(400.0, 600.0);
pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
let mut state = Log::default();
{
let s: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(
&mut ctx,
&ev(PointerPhase::Down, fab_center.x, fab_center.y),
);
}
assert!(pod.is_focused(), "the trigger press claims focus");
let mut counter = 0u64;
let closed = fab_menu::<Log, _>(icon_stub::<Log>(), false, vec![item(0)], |s: &mut Log| {
s.toggles += 1
});
let opened = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |s: &mut Log| {
s.toggles += 1
});
{
let widget = pod
.widget_mut()
.downcast_mut::<FabMenuWidget>()
.expect("the pod wraps a FabMenuWidget");
View::<Log>::rebuild(&opened, &closed, widget, &mut BuildCtx::new(&mut counter));
View::<Log>::rebuild(&closed, &opened, widget, &mut BuildCtx::new(&mut counter));
}
assert!(
pod.is_focused(),
"a same-identity rebuild (open then close) never touches the pod's \
recorded focus path on its own"
);
pod.set_focused(false);
pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
{
let s: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(
&mut ctx,
&ev(PointerPhase::Down, fab_center.x, fab_center.y),
);
}
assert!(
pod.is_focused(),
"reopening after a close still lets a fresh trigger press re-claim focus"
);
}
#[test]
fn is_controlled_open_only_moves_via_rebuild() {
let mut w = build_menu(false, 1);
assert!(!w.open);
let prev = fab_menu::<Log, _>(icon_stub::<Log>(), false, vec![item(0)], |_s: &mut Log| {});
let next = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |_s: &mut Log| {});
let mut counter = 0u64;
View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(w.open, "open state moved on rebuild (controlled)");
assert!(
w.fab_morph.is_animating(),
"the trigger morph relaunched toward the open target"
);
}
#[derive(Default)]
struct RectRecorder {
rrects: Vec<(Point, Size, f64, Color)>,
rects: Vec<(Point, Size, Color)>,
fills: Vec<BezPath>,
layers: Vec<(Point, Size, f32)>,
pops: usize,
}
impl PaintScene for RectRecorder {
fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
self.rects.push((o, s, c));
}
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 fill_path(&mut self, _origin: Point, path: &BezPath, _brush: &Brush) {
self.fills.push(path.clone());
}
fn push_layer(&mut self, o: Point, s: Size, alpha: f32) {
self.layers.push((o, s, alpha));
}
fn pop_layer(&mut self) {
self.pops += 1;
}
}
fn paint_at(w: &mut FabMenuWidget, millis: u64, rec: &mut RectRecorder) {
let mut pctx = PaintCtx::for_test(
Point::ZERO,
Size::new(400.0, 600.0),
FrameTime::from_nanos(millis * 1_000_000),
);
w.paint(&mut pctx, rec);
}
fn set_open(w: &mut FabMenuWidget, from: bool, to: bool, item_count: usize) {
let prev = fab_menu::<Log, _>(
icon_stub(),
from,
(0..item_count).map(item).collect(),
|_s: &mut Log| {},
);
let next = fab_menu::<Log, _>(
icon_stub(),
to,
(0..item_count).map(item).collect(),
|_s: &mut Log| {},
);
let mut counter = 0u64;
View::<Log>::rebuild(&next, &prev, w, &mut BuildCtx::new(&mut counter));
}
#[test]
fn closed_paint_draws_only_the_trigger_shape() {
let mut w = build_menu(false, 2);
laid_out(&mut w);
let mut rec = RectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0));
w.paint(&mut pctx, &mut rec);
assert!(rec.rects.is_empty(), "no scrim while closed");
assert!(rec.rrects.is_empty(), "no item containers while closed");
assert!(rec.layers.is_empty(), "no item fade layers while closed");
assert_eq!(rec.fills.len(), 1, "only the trigger's morphed outline");
}
#[test]
fn open_paint_draws_scrim_and_item_containers() {
let mut w = build_menu(true, 2);
laid_out(&mut w);
open_fully(&mut w);
let mut rec = RectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0));
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rects.len(), 1, "one full-area scrim fill");
assert_eq!(rec.rrects.len(), 2, "2 item containers");
assert_eq!(rec.fills.len(), 1, "the trigger's morphed outline");
assert_eq!(rec.layers.len(), 2, "one fade layer per visible item");
assert_eq!(rec.pops, 2, "every pushed layer is popped again");
assert!(
rec.layers.iter().all(|l| l.2 == 1.0),
"settled open: t == 1"
);
assert_eq!(rec.rrects[0].3, PRIMARY_CONTAINER);
assert_eq!(rec.rrects[1].3, SECONDARY_CONTAINER);
assert_eq!(rec.rects[0].2, with_alpha(SCRIM_FALLBACK, 0.0));
}
#[test]
fn themed_paint_resolves_container_family_tokens() {
let theme = crate::baseline();
let mut w = build_menu(true, 3);
laid_out(&mut w);
open_fully(&mut w);
let mut rec = RectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.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[1].3, scheme.secondary_container);
assert_eq!(rec.rrects[2].3, scheme.tertiary_container);
}
#[test]
fn item_shape_is_a_full_pill() {
assert_eq!(item_radius(), ITEM_HEIGHT / 2.0);
}
#[test]
fn a_mid_cascade_item_fades_as_one_unit_through_its_own_layer() {
let mut w = build_menu(false, 2);
laid_out(&mut w);
set_open(&mut w, false, true, 2);
let mut rec = RectRecorder::default();
paint_at(&mut w, 0, &mut rec);
rec = RectRecorder::default();
paint_at(&mut w, 20, &mut rec);
let t = w.item_anim[0].value_clamped();
assert!(t > 0.0 && t < 1.0, "item 0 is mid-reveal (t={t})");
assert_eq!(
w.item_anim[1].value_clamped(),
0.0,
"item 1 hasn't launched"
);
assert_eq!(rec.layers.len(), 1, "one fade layer per *visible* item");
assert_eq!(rec.pops, 1, "and it is popped again");
assert_eq!(
rec.layers[0].2, t as f32,
"the layer carries the item's own reveal progress"
);
let item = w.item_rects[0];
assert_eq!(rec.layers[0].0, item.origin(), "layer covers the item row");
assert_eq!(rec.layers[0].1, item.size());
assert_eq!(rec.rrects.len(), 1, "only the revealed item's container");
assert_eq!(
rec.rrects[0].3, PRIMARY_CONTAINER,
"the container paints at full alpha INSIDE the layer — fading it \
again there would double-fade it, and fading it alone (while the \
icon/label pods paint opaque regardless) is what made a low-`t` \
item read as a bare content row with no pill behind it"
);
}
#[test]
fn a_menu_built_closed_never_flings_and_never_paints_an_item() {
let mut w = build_menu(false, 3);
laid_out(&mut w);
assert_eq!(
w.cascade_launched,
vec![true; 3],
"a widget built at rest mounts fully-launched: an unseeded closed \
mount lets the first paint fling every item from rest with the \
retreat velocity, dipping below zero and rebounding back above it"
);
assert!(
w.item_anim
.iter()
.all(|a| !a.is_animating() && a.value() == 0.0),
"every item spring rests exactly at 0"
);
assert!(!w.fab_morph.is_animating() && w.fab_morph.value() == 0.0);
let mut rec = RectRecorder::default();
for frame in 0..40u64 {
paint_at(&mut w, frame * 16, &mut rec);
}
assert!(rec.rrects.is_empty(), "no item container ever paints");
assert!(rec.layers.is_empty(), "no item fade layer ever pushes");
assert!(
w.item_anim.iter().all(|a| !a.is_animating()),
"and no item spring ever launched"
);
}
#[test]
fn the_closing_retreat_stops_at_zero_instead_of_ringing_back_above_it() {
let mut w = build_menu(true, 1);
laid_out(&mut w);
open_fully(&mut w);
set_open(&mut w, true, false, 1);
let mut rec = RectRecorder::default();
for frame in 0..13u64 {
paint_at(&mut w, frame * 16, &mut rec); }
assert!(
!w.item_anim[0].is_animating(),
"the retreating spring stops the frame it reaches zero"
);
assert_eq!(w.item_anim[0].value(), 0.0, "and stops exactly *at* zero");
let mut after = RectRecorder::default();
for frame in 13..60u64 {
paint_at(&mut w, frame * 16, &mut after); }
assert!(
after.rrects.is_empty() && after.layers.is_empty(),
"no rebound ever paints the item back on"
);
}
#[test]
fn the_trigger_morphs_retreat_rebound_is_imperceptible_so_it_stays_unclamped() {
let mut anim = AnimationController::new(ANIM_PERIOD);
snap_anim(&mut anim, true, FrameTime::ZERO);
anim.fling(-FLING_VELOCITY, FAB_SHAPE_SPRING);
let (mut crossed, mut peak) = (false, 0.0f64);
for frame in 0..1000u64 {
anim.advance(FrameTime::from_nanos(frame * 1_000_000)); let v = anim.value();
crossed |= v <= 0.0;
if crossed {
peak = peak.max(v);
}
}
assert!(crossed, "the retreat does cross its target");
let px = MAIN_CONTAINER_CLOSED - lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, peak);
assert!(
px < 0.1,
"rebound peak {peak} = {px}px of container size — big enough to see, \
so the retreat clamp now needs to cover the morph too"
);
}
#[test]
fn reduce_motion_snaps_the_trigger_and_items_without_requesting_a_frame() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let mut w = build_menu(true, 2);
laid_out(&mut w);
let mut rec = RectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0)).with_theme(&theme);
w.paint(&mut pctx, &mut rec);
assert_eq!(w.fab_morph.value_clamped(), 1.0, "snapped straight open");
assert!(
w.item_anim.iter().all(|a| a.value_clamped() == 1.0),
"every item snapped straight open"
);
assert!(
!pctx.needs_frame(),
"reduce_motion must not request a continuation frame"
);
}
#[test]
fn inspect_surfaces_icon_and_label_pods_per_item_plus_the_trigger() {
fn logic(_s: &mut ()) -> FabMenuView<()> {
fab_menu::<(), _>(
icon_stub::<()>(),
true,
vec![
fab_menu_item(icon_stub::<()>(), "alpha", |_s: &mut ()| {}),
fab_menu_item(icon_stub::<()>(), "beta", |_s: &mut ()| {}),
],
|_s: &mut ()| {},
)
}
let mut root: RenderRoot<(), FabMenuView<()>> = 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(400.0, 600.0), &mut tcx as &mut dyn Any);
let nodes = root.inspect();
let short_names: Vec<&str> = nodes
.iter()
.map(|n| {
let bare = n.type_name.split('<').next().unwrap_or(n.type_name);
bare.rsplit("::").next().unwrap_or(bare)
})
.collect();
assert_eq!(
short_names
.iter()
.filter(|n| **n == "FabMenuWidget")
.count(),
1
);
assert_eq!(
nodes[0].children.len(),
5,
"two items x (icon + label), plus the trigger icon"
);
assert_eq!(
short_names.iter().filter(|n| **n == "TextWidget").count(),
5
);
}
#[test]
fn semantics_reports_closed_trigger_then_open_menu() {
fn logic_closed(_s: &mut ()) -> FabMenuView<()> {
fab_menu::<(), _>(leaf_any(24.0, 24.0), false, vec![], |_s: &mut ()| {})
.label("Compose")
}
let mut root: frust_core::RenderRoot<(), FabMenuView<()>> = frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic_closed, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Button)
.expect("closed menu contributes a Role::Button trigger node");
assert_eq!(node.label(), Some("Compose"));
assert_eq!(node.is_expanded(), Some(false));
fn logic_open(_s: &mut ()) -> FabMenuView<()> {
fab_menu::<(), _>(
leaf_any(24.0, 24.0),
true,
vec![fab_menu_item(
leaf_any(24.0, 24.0),
"New note",
|_s: &mut ()| {},
)],
|_s: &mut ()| {},
)
}
let mut root: frust_core::RenderRoot<(), FabMenuView<()>> = frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic_open, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let menu = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Menu)
.expect("an open menu contributes a Role::Menu container node");
assert_eq!(menu.1.children().len(), 1);
let menu_item = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::MenuItem)
.expect("an item contributes a Role::MenuItem node");
assert_eq!(menu_item.1.label(), Some("New note"));
}
}