use std::rc::Rc;
use std::time::Duration;
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx, View, ViewSeq, Widget,
};
use frust::{
AnimationController, Curve, FrameTime, IconSource, ShapeScale, SpringDesc, Theme, Tween, icon,
};
use kurbo::{Point, Size, Vec2};
use peniko::Color;
use crate::icon_button::{IconButtonSize, IconButtonVariant, icon_button};
use crate::menu::{MenuNode, MenuSelection, menu, menu_entry};
use crate::overlay::{OverlayAlign, OverlayAnchor, overlay_anchor};
pub const TOOLBAR_HEIGHT: f64 = 64.0;
pub const TOOLBAR_FLOATING_PAD: f64 = 8.0;
pub const TOOLBAR_DOCKED_PAD_X: f64 = 16.0;
pub const TOOLBAR_GAP: f64 = 4.0;
pub const TOOLBAR_TO_FAB_GAP: f64 = 8.0;
pub const TOOLBAR_SCREEN_OFFSET: f64 = 16.0;
pub const TOOLBAR_FAB_BASELINE: f64 = 56.0;
pub const TOOLBAR_FAB_MEDIUM: f64 = 80.0;
pub const TOOLBAR_MAX_INLINE_ACTIONS: usize = 4;
pub const TOOLBAR_EXPAND_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 800.0,
damping_ratio: 0.6,
};
pub const TOOLBAR_SETTLE_VELOCITY: f64 = 150.0;
pub const TOOLBAR_REVEAL_FADE_START: f64 = 0.5;
const REVEAL_OVERSHOOT: f64 = 1.2;
const RAMP_PERIOD: Duration = Duration::from_millis(300);
const RAMP_LAUNCH_VELOCITY: f64 = 1e-3;
const RAMP_EPSILON: f64 = 1e-6;
const SURFACE_CONTAINER: Color = Color::from_rgb8(0xF3, 0xED, 0xF7);
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
const ON_PRIMARY_CONTAINER: Color = Color::from_rgb8(0x21, 0x00, 0x5D);
const TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
const ON_TERTIARY_CONTAINER: Color = Color::from_rgb8(0x31, 0x11, 0x1D);
const LEVEL1_SHADOW_Y_OFFSET: f64 = 1.5;
const LEVEL1_SHADOW_BLUR: f64 = 1.0;
const LEVEL2_SHADOW_Y_OFFSET: f64 = 2.5;
const LEVEL2_SHADOW_BLUR: f64 = 3.0;
const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
const OVERFLOW_LABEL: &str = "More options";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToolbarVariant {
#[default]
Floating,
Docked,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToolbarColorStyle {
#[default]
Standard,
Vibrant,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToolbarElevation {
#[default]
None,
Level1,
Level2,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToolbarFabPosition {
Start,
#[default]
End,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToolbarExitDirection {
Top,
#[default]
Bottom,
Start,
End,
}
impl ToolbarExitDirection {
pub fn is_vertical(self) -> bool {
matches!(self, Self::Top | Self::Bottom)
}
pub fn offset(self, distance: f64) -> Vec2 {
match self {
Self::Top => Vec2::new(0.0, -distance),
Self::Bottom => Vec2::new(0.0, distance),
Self::Start => Vec2::new(-distance, 0.0),
Self::End => Vec2::new(distance, 0.0),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToolbarSize {
Small,
#[default]
Medium,
Large,
}
impl ToolbarSize {
pub fn icon_button_size(self) -> IconButtonSize {
match self {
Self::Small => IconButtonSize::Xs,
Self::Medium | Self::Large => IconButtonSize::Sm,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ToolbarColors {
pub container: Color,
pub content: Color,
pub fab_container: Color,
pub fab_content: Color,
}
impl ToolbarColors {
pub fn resolve(theme: Option<&Theme>, style: ToolbarColorStyle) -> Self {
match (theme, style) {
(Some(theme), ToolbarColorStyle::Standard) => {
let s = theme.scheme();
Self {
container: s.surface_container,
content: s.on_surface,
fab_container: s.primary_container,
fab_content: s.on_primary_container,
}
}
(Some(theme), ToolbarColorStyle::Vibrant) => {
let s = theme.scheme();
Self {
container: s.primary_container,
content: s.on_primary_container,
fab_container: s.tertiary_container,
fab_content: s.on_tertiary_container,
}
}
(None, ToolbarColorStyle::Standard) => Self {
container: SURFACE_CONTAINER,
content: ON_SURFACE,
fab_container: PRIMARY_CONTAINER,
fab_content: ON_PRIMARY_CONTAINER,
},
(None, ToolbarColorStyle::Vibrant) => Self {
container: PRIMARY_CONTAINER,
content: ON_PRIMARY_CONTAINER,
fab_container: TERTIARY_CONTAINER,
fab_content: ON_TERTIARY_CONTAINER,
},
}
}
}
fn clamp01(value: f64) -> f64 {
if value.is_nan() {
0.0
} else {
value.clamp(0.0, 1.0)
}
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
fn finite_or_zero(constraint: f64) -> f64 {
if constraint.is_finite() {
constraint
} else {
0.0
}
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_radius(theme: Option<&Theme>, variant: ToolbarVariant, width: f64, height: f64) -> f64 {
let token = match (theme, variant) {
(Some(theme), ToolbarVariant::Floating) => theme.shape.full,
(Some(theme), ToolbarVariant::Docked) => theme.shape.none,
(None, ToolbarVariant::Floating) => f64::INFINITY,
(None, ToolbarVariant::Docked) => 0.0,
};
ShapeScale::resolve(token, width, height)
}
fn resolve_shadow(theme: Option<&Theme>, level: ToolbarElevation) -> Option<(f64, f64, Color)> {
match (theme, level) {
(_, ToolbarElevation::None) => None,
(Some(theme), level) => {
let spec = match level {
ToolbarElevation::Level1 => theme.elevation.level1,
_ => theme.elevation.level2,
};
let shadow = spec.shadow(theme.brightness);
let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
Some((shadow.blur_std_dev, shadow.y_offset, color))
}
(None, ToolbarElevation::Level1) => Some((
LEVEL1_SHADOW_BLUR,
LEVEL1_SHADOW_Y_OFFSET,
FALLBACK_SHADOW_COLOR,
)),
(None, ToolbarElevation::Level2) => Some((
LEVEL2_SHADOW_BLUR,
LEVEL2_SHADOW_Y_OFFSET,
FALLBACK_SHADOW_COLOR,
)),
}
}
#[derive(Debug)]
pub enum ToolbarMorph {}
impl ToolbarMorph {
pub fn size(progress: f64) -> f64 {
if progress.is_nan() {
return TOOLBAR_FAB_MEDIUM;
}
lerp(TOOLBAR_FAB_MEDIUM, TOOLBAR_FAB_BASELINE, progress).max(0.0)
}
pub fn reveal_alpha(progress: f64) -> f64 {
Curve::EaseIn
.interval(TOOLBAR_REVEAL_FADE_START, 1.0)
.transform(clamp01(progress))
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ToolbarScrollHide {
limit: f64,
offset: f64,
}
impl ToolbarScrollHide {
pub fn new(exit_extent: f64) -> Self {
Self {
limit: -finite_or_zero(exit_extent).abs(),
offset: 0.0,
}
}
pub fn for_bar(cross_extent: f64) -> Self {
Self::new(cross_extent.abs() + TOOLBAR_SCREEN_OFFSET)
}
pub fn offset(&self) -> f64 {
self.offset
}
pub fn offset_limit(&self) -> f64 {
self.limit
}
pub fn scroll(&mut self, delta: f64) {
if !delta.is_finite() {
return;
}
self.set_offset(self.offset - delta);
}
pub fn collapsed_fraction(&self) -> f64 {
if self.limit == 0.0 {
return 0.0;
}
clamp01(self.offset / self.limit)
}
pub fn is_hidden(&self) -> bool {
self.collapsed_fraction() >= 1.0
}
pub fn show(&mut self) {
self.offset = 0.0;
}
pub fn hide(&mut self) {
self.offset = self.limit;
}
pub fn toggle(&mut self) {
if self.collapsed_fraction() < 0.5 {
self.hide();
} else {
self.show();
}
}
pub fn settle(&mut self, velocity: f64) -> bool {
if self.offset == 0.0 || self.offset == self.limit {
return self.offset == 0.0;
}
let hide = if velocity.abs() > TOOLBAR_SETTLE_VELOCITY {
velocity > 0.0
} else {
self.collapsed_fraction() >= 0.5
};
if hide {
self.hide();
} else {
self.show();
}
!hide
}
fn set_offset(&mut self, value: f64) {
self.offset = if self.limit <= 0.0 {
value.clamp(self.limit, 0.0)
} else {
value.clamp(0.0, self.limit)
};
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ToolbarPartition {
pub inline: Vec<usize>,
pub overflow: Vec<usize>,
}
pub fn partition_actions(
count: usize,
max_inline: usize,
trigger: Option<usize>,
) -> ToolbarPartition {
let trigger = trigger.filter(|index| *index < count);
let budget = max_inline.saturating_sub(usize::from(trigger.is_some()));
let mut partition = ToolbarPartition::default();
let mut used = 0usize;
for index in 0..count {
if Some(index) == trigger {
partition.inline.push(index);
} else if used < budget {
partition.inline.push(index);
used += 1;
} else {
partition.overflow.push(index);
}
}
partition
}
type OnPress<State> = Rc<dyn Fn(&mut State)>;
pub struct ToolbarAction<State: 'static> {
icon: IconSource,
label: Option<String>,
tooltip: Option<String>,
semantic_label: Option<String>,
enabled: bool,
destructive: bool,
active: bool,
expand_trigger: bool,
on_press: OnPress<State>,
}
pub fn toolbar_action<State: 'static, F: Fn(&mut State) + 'static>(
icon: IconSource,
on_press: F,
) -> ToolbarAction<State> {
ToolbarAction {
icon,
label: None,
tooltip: None,
semantic_label: None,
enabled: true,
destructive: false,
active: false,
expand_trigger: false,
on_press: Rc::new(on_press),
}
}
impl<State: 'static> ToolbarAction<State> {
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn destructive(mut self, destructive: bool) -> Self {
self.destructive = destructive;
self
}
pub fn active(mut self, active: bool) -> Self {
self.active = active;
self
}
pub fn expand_trigger(mut self, trigger: bool) -> Self {
self.expand_trigger = trigger;
self
}
pub fn menu_label(&self, index: usize) -> String {
self.label
.as_deref()
.or(self.tooltip.as_deref())
.or(self.semantic_label.as_deref())
.map(str::to_owned)
.unwrap_or_else(|| format!("Action {}", index + 1))
}
fn inline_view(&self, size: ToolbarSize) -> AnyView<State> {
let on_press = self.on_press.clone();
let mut button = icon_button::<State, _>(
frust::authoring::any::<State, _>(icon(self.icon)),
move |state: &mut State| on_press(state),
)
.size(size.icon_button_size())
.enabled(self.enabled)
.variant(if self.active || self.expand_trigger {
IconButtonVariant::Filled
} else {
IconButtonVariant::Standard
});
if let Some(label) = self.semantic_label.as_deref().or(self.tooltip.as_deref()) {
button = button.semantic_label(label);
}
frust::authoring::any::<State, _>(button)
}
}
struct RampTrack {
value: f64,
target: f64,
tween: Tween<f64>,
driver: AnimationController,
}
impl RampTrack {
fn resting(value: f64) -> Self {
Self {
value,
target: value,
tween: Tween::new(value, value),
driver: AnimationController::new(RAMP_PERIOD),
}
}
fn set_target(&mut self, target: f64) {
if (self.target - target).abs() < RAMP_EPSILON {
return;
}
self.target = target;
self.tween = Tween::new(self.value, target);
self.driver = AnimationController::new(RAMP_PERIOD);
self.driver
.fling(RAMP_LAUNCH_VELOCITY, TOOLBAR_EXPAND_SPRING);
}
fn jump(&mut self, to: f64) {
self.value = to;
self.target = to;
self.tween = Tween::new(to, to);
self.driver.stop();
}
fn snap(&mut self) -> bool {
let moved = self.value != self.target;
self.jump(self.target);
moved
}
fn advance(&mut self, now: FrameTime) -> bool {
let animating = self.driver.advance(now);
self.value = self.tween.lerp(self.driver.value());
animating
}
}
type OnOverflow<State> = Rc<dyn Fn(&mut State)>;
pub struct ToolbarView<State: 'static> {
variant: ToolbarVariant,
color_style: ToolbarColorStyle,
elevation: Option<ToolbarElevation>,
size: ToolbarSize,
leading: Vec<AnyView<State>>,
center: Vec<AnyView<State>>,
trailing: Vec<AnyView<State>>,
fab: Option<AnyView<State>>,
actions: Vec<ToolbarAction<State>>,
max_inline_actions: usize,
overflow_icon: IconSource,
overflow_label: String,
on_overflow: Option<OnOverflow<State>>,
overflow_anchor: Option<OverlayAnchor>,
expanded: bool,
fab_expands_toolbar: bool,
fab_position: ToolbarFabPosition,
visible: bool,
hidden_fraction: Option<f64>,
exit_direction: ToolbarExitDirection,
exit_extent: Option<f64>,
semantic_label: Option<String>,
}
fn toolbar<State: 'static>(variant: ToolbarVariant) -> ToolbarView<State> {
ToolbarView {
variant,
color_style: ToolbarColorStyle::default(),
elevation: None,
size: ToolbarSize::default(),
leading: Vec::new(),
center: Vec::new(),
trailing: Vec::new(),
fab: None,
actions: Vec::new(),
max_inline_actions: TOOLBAR_MAX_INLINE_ACTIONS,
overflow_icon: crate::icons::MORE_VERT,
overflow_label: OVERFLOW_LABEL.to_owned(),
on_overflow: None,
overflow_anchor: None,
expanded: true,
fab_expands_toolbar: true,
fab_position: ToolbarFabPosition::default(),
visible: true,
hidden_fraction: None,
exit_direction: ToolbarExitDirection::default(),
exit_extent: None,
semantic_label: None,
}
}
pub fn floating_toolbar<State: 'static>() -> ToolbarView<State> {
toolbar(ToolbarVariant::Floating)
}
#[allow(non_snake_case)]
pub fn FloatingToolbar<State: 'static>() -> ToolbarView<State> {
floating_toolbar()
}
pub fn docked_toolbar<State: 'static>() -> ToolbarView<State> {
toolbar(ToolbarVariant::Docked)
}
#[allow(non_snake_case)]
pub fn DockedToolbar<State: 'static>() -> ToolbarView<State> {
docked_toolbar()
}
impl<State: 'static> ToolbarView<State> {
pub fn leading<M>(mut self, leading: impl ViewSeq<State, M>) -> Self {
self.leading.clear();
leading.extend_views(&mut self.leading);
self
}
pub fn center<M>(mut self, center: impl ViewSeq<State, M>) -> Self {
self.center.clear();
center.extend_views(&mut self.center);
self
}
pub fn trailing<M>(mut self, trailing: impl ViewSeq<State, M>) -> Self {
self.trailing.clear();
trailing.extend_views(&mut self.trailing);
self
}
pub fn fab(mut self, fab: impl View<State>) -> Self {
self.fab = Some(AnyView::new(fab));
self
}
pub fn actions(mut self, actions: Vec<ToolbarAction<State>>) -> Self {
self.actions = actions;
self
}
pub fn max_inline_actions(mut self, max: usize) -> Self {
self.max_inline_actions = max;
self
}
pub fn overflow_icon(mut self, icon: IconSource) -> Self {
self.overflow_icon = icon;
self
}
pub fn overflow_label(mut self, label: impl Into<String>) -> Self {
self.overflow_label = label.into();
self
}
pub fn on_overflow<F: Fn(&mut State) + 'static>(mut self, on_overflow: F) -> Self {
self.on_overflow = Some(Rc::new(on_overflow));
self
}
pub fn overflow_anchor(mut self, anchor: &OverlayAnchor) -> Self {
self.overflow_anchor = Some(anchor.clone());
self
}
pub fn color_style(mut self, style: ToolbarColorStyle) -> Self {
self.color_style = style;
self
}
pub fn elevation(mut self, elevation: ToolbarElevation) -> Self {
self.elevation = Some(elevation);
self
}
pub fn size(mut self, size: ToolbarSize) -> Self {
self.size = size;
self
}
pub fn expanded(mut self, expanded: bool) -> Self {
self.expanded = expanded;
self
}
pub fn fab_expands_toolbar(mut self, expands: bool) -> Self {
self.fab_expands_toolbar = expands;
self
}
pub fn fab_position(mut self, position: ToolbarFabPosition) -> Self {
self.fab_position = position;
self
}
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn hidden_fraction(mut self, fraction: Option<f64>) -> Self {
self.hidden_fraction = fraction;
self
}
pub fn exit_direction(mut self, direction: ToolbarExitDirection) -> Self {
self.exit_direction = direction;
self
}
pub fn exit_extent(mut self, extent: Option<f64>) -> Self {
self.exit_extent = extent;
self
}
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
fn trigger_index(&self) -> Option<usize> {
self.actions.iter().position(|a| a.expand_trigger)
}
fn partition(&self) -> ToolbarPartition {
partition_actions(
self.actions.len(),
self.max_inline_actions,
self.trigger_index(),
)
}
fn overflow_entries(&self) -> Vec<(MenuNode, OnPress<State>)> {
self.partition()
.overflow
.into_iter()
.map(|index| {
let action = &self.actions[index];
let node: MenuNode = menu_entry(action.menu_label(index))
.leading(action.icon)
.enabled(action.enabled)
.destructive(action.destructive)
.into();
(node, action.on_press.clone())
})
.collect()
}
fn action_views(&self) -> Vec<AnyView<State>> {
let split = self.partition();
let mut views: Vec<AnyView<State>> = split
.inline
.iter()
.map(|index| self.actions[*index].inline_view(self.size))
.collect();
if !split.overflow.is_empty() {
views.push(self.trigger_view());
}
views
}
fn trigger_view(&self) -> AnyView<State> {
let on_overflow = self.on_overflow.clone();
let trigger = icon_button::<State, _>(
frust::authoring::any::<State, _>(icon(self.overflow_icon)),
move |state: &mut State| {
if let Some(callback) = &on_overflow {
callback(state);
}
},
)
.size(self.size.icon_button_size())
.semantic_label(self.overflow_label.clone());
match &self.overflow_anchor {
Some(anchor) => frust::authoring::any::<State, _>(overlay_anchor(anchor, trigger)),
None => frust::authoring::any::<State, _>(trigger),
}
}
pub fn overflow_menu(&self, open: bool) -> AnyView<State> {
let anchor = self.overflow_anchor.clone().unwrap_or_default();
let entries = self.overflow_entries();
let nodes: Vec<MenuNode> = entries.iter().map(|(node, _)| node.clone()).collect();
let targets: Vec<OnPress<State>> = entries.into_iter().map(|(_, target)| target).collect();
frust::authoring::any::<State, _>(
menu(nodes, move |state: &mut State, selection: MenuSelection| {
if let Some(target) = targets.get(selection.index) {
target(state);
}
})
.anchor(&anchor)
.align(OverlayAlign::End)
.open(open),
)
}
fn resolved_elevation(&self) -> ToolbarElevation {
self.elevation.unwrap_or(if self.fab.is_some() {
ToolbarElevation::Level1
} else {
ToolbarElevation::None
})
}
fn resting_progress(&self) -> f64 {
if self.morphs() && !self.expanded {
0.0
} else {
1.0
}
}
fn morphs(&self) -> bool {
self.variant == ToolbarVariant::Floating && self.fab.is_some() && self.fab_expands_toolbar
}
fn adjacent_fab(&self) -> bool {
self.variant == ToolbarVariant::Floating && self.fab.is_some()
}
}
fn slot_views<'a, State: 'static>(
view: &'a ToolbarView<State>,
actions: &'a [AnyView<State>],
) -> Vec<&'a AnyView<State>> {
let mut views = Vec::with_capacity(
view.leading.len()
+ view.center.len()
+ actions.len()
+ view.trailing.len()
+ usize::from(view.fab.is_some()),
);
views.extend(view.leading.iter());
views.extend(view.center.iter());
views.extend(actions.iter());
views.extend(view.trailing.iter());
if let Some(fab) = &view.fab {
views.push(fab);
}
views
}
pub struct ToolbarWidget {
variant: ToolbarVariant,
color_style: ToolbarColorStyle,
elevation: ToolbarElevation,
slots: Vec<ChildPod>,
leading_count: usize,
center_count: usize,
trailing_count: usize,
has_fab: bool,
adjacent_fab: bool,
fab_expands: bool,
fab_position: ToolbarFabPosition,
semantic_label: Option<String>,
exit_direction: ToolbarExitDirection,
exit_extent: Option<f64>,
hidden_pinned: Option<f64>,
expand: RampTrack,
hide: RampTrack,
pill_size: Size,
pill_origin: Point,
reveal: f64,
}
impl<State: 'static> View<State> for ToolbarView<State> {
type Element = ToolbarWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> ToolbarWidget {
let actions = self.action_views();
let slots = slot_views(self, &actions)
.into_iter()
.map(|view| frust::authoring::build_child(view, ctx))
.collect();
let hidden = self
.hidden_fraction
.map_or(if self.visible { 0.0 } else { 1.0 }, clamp01);
ToolbarWidget {
variant: self.variant,
color_style: self.color_style,
elevation: self.resolved_elevation(),
slots,
leading_count: self.leading.len(),
center_count: self.center.len() + actions.len(),
trailing_count: self.trailing.len(),
has_fab: self.fab.is_some(),
adjacent_fab: self.adjacent_fab(),
fab_expands: self.fab_expands_toolbar,
fab_position: self.fab_position,
semantic_label: self.semantic_label.clone(),
exit_direction: self.exit_direction,
exit_extent: self.exit_extent,
hidden_pinned: self.hidden_fraction.map(clamp01),
expand: RampTrack::resting(self.resting_progress()),
hide: RampTrack::resting(hidden),
pill_size: Size::ZERO,
pill_origin: Point::ZERO,
reveal: 1.0,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut ToolbarWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
let prev_actions = prev.action_views();
let next_actions = self.action_views();
let prev_views = slot_views(prev, &prev_actions);
let next_views = slot_views(self, &next_actions);
flags |= frust::authoring::rebuild_children(
&prev_views,
&next_views,
&mut element.slots,
ctx,
|view: &&AnyView<State>| *view,
|_| None,
);
let center_count = self.center.len() + next_actions.len();
for (slot, next) in [
(&mut element.leading_count, self.leading.len()),
(&mut element.center_count, center_count),
(&mut element.trailing_count, self.trailing.len()),
] {
if *slot != next {
*slot = next;
flags |= ChangeFlags::LAYOUT;
}
}
let now_fab = self.fab.is_some();
if element.has_fab != now_fab {
element.has_fab = now_fab;
flags |= ChangeFlags::LAYOUT;
}
let adjacent = self.adjacent_fab();
if element.adjacent_fab != adjacent {
element.adjacent_fab = adjacent;
flags |= ChangeFlags::LAYOUT;
}
if element.fab_expands != self.fab_expands_toolbar {
element.fab_expands = self.fab_expands_toolbar;
flags |= ChangeFlags::LAYOUT;
}
if element.fab_position != self.fab_position {
element.fab_position = self.fab_position;
flags |= ChangeFlags::LAYOUT;
}
if element.variant != self.variant {
element.variant = self.variant;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.color_style != self.color_style {
element.color_style = self.color_style;
flags |= ChangeFlags::PAINT;
}
let elevation = self.resolved_elevation();
if element.elevation != elevation {
element.elevation = elevation;
flags |= ChangeFlags::PAINT;
}
if element.exit_direction != self.exit_direction {
element.exit_direction = self.exit_direction;
flags |= ChangeFlags::LAYOUT;
}
if element.exit_extent != self.exit_extent {
element.exit_extent = self.exit_extent;
flags |= ChangeFlags::LAYOUT;
}
if element.semantic_label != self.semantic_label {
element.semantic_label = self.semantic_label.clone();
}
let progress = self.resting_progress();
if (element.expand.target - progress).abs() >= RAMP_EPSILON {
element.expand.set_target(progress);
flags |= ChangeFlags::LAYOUT;
}
let pinned = self.hidden_fraction.map(clamp01);
element.hidden_pinned = pinned;
match pinned {
Some(fraction) => {
if element.hide.value != fraction {
element.hide.jump(fraction);
flags |= ChangeFlags::LAYOUT;
}
}
None => {
let hidden = if self.visible { 0.0 } else { 1.0 };
if (element.hide.target - hidden).abs() >= RAMP_EPSILON {
element.hide.set_target(hidden);
flags |= ChangeFlags::LAYOUT;
}
}
}
flags
}
fn teardown(&self, element: &mut ToolbarWidget, ctx: &mut BuildCtx<'_>) {
let actions = self.action_views();
for (view, pod) in slot_views(self, &actions)
.into_iter()
.zip(element.slots.iter_mut())
{
frust::authoring::teardown_child(view, pod, ctx);
}
}
}
impl ToolbarWidget {
fn morphs(&self) -> bool {
self.adjacent_fab && self.fab_expands
}
fn fab_index(&self) -> Option<usize> {
self.has_fab.then(|| self.slots.len() - 1)
}
fn row_len(&self) -> usize {
let row = self.leading_count + self.center_count + self.trailing_count;
if self.has_fab && !self.adjacent_fab {
row + 1
} else {
row
}
}
fn hidden(&self) -> f64 {
self.hidden_pinned
.unwrap_or_else(|| clamp01(self.hide.value))
}
fn reveal_window(&self, box_size: Size) -> (Point, Size) {
if !self.morphs() || self.reveal >= 1.0 {
return (Point::ZERO, box_size);
}
let natural = self.pill_size.width;
let (x0, x1) = match self.fab_position {
ToolbarFabPosition::End => (
self.pill_origin.x + natural * (1.0 - self.reveal),
box_size.width,
),
ToolbarFabPosition::Start => (0.0, self.pill_origin.x + natural * self.reveal),
};
let x0 = x0.clamp(0.0, box_size.width);
let x1 = x1.clamp(x0, box_size.width);
(Point::new(x0, 0.0), Size::new(x1 - x0, box_size.height))
}
}
impl Widget for ToolbarWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let progress = if self.morphs() {
self.expand.value
} else {
1.0
};
self.reveal = clamp01(progress);
let row_len = self.row_len();
let row_bc = BoxConstraints::loose(Size::new(f64::INFINITY, TOOLBAR_HEIGHT));
let sizes: Vec<Size> = self.slots[..row_len]
.iter_mut()
.map(|pod| pod.layout_child(ctx, &row_bc))
.collect();
let pill_width = match self.variant {
ToolbarVariant::Floating => {
let mut x = TOOLBAR_FLOATING_PAD;
for (pod, size) in self.slots[..row_len].iter_mut().zip(sizes.iter()) {
pod.set_origin(Point::new(x, (TOOLBAR_HEIGHT - size.height) / 2.0));
x += size.width + TOOLBAR_GAP;
}
if row_len > 0 {
x -= TOOLBAR_GAP;
}
x + TOOLBAR_FLOATING_PAD
}
ToolbarVariant::Docked => {
let width = finite_or_zero(bc.max().width);
let mut left = TOOLBAR_DOCKED_PAD_X;
for (pod, size) in self.slots[..self.leading_count]
.iter_mut()
.zip(sizes[..self.leading_count].iter())
{
pod.set_origin(Point::new(left, (TOOLBAR_HEIGHT - size.height) / 2.0));
left += size.width + TOOLBAR_GAP;
}
if self.leading_count > 0 {
left -= TOOLBAR_GAP;
}
let tail_start = self.leading_count + self.center_count;
let mut right = width - TOOLBAR_DOCKED_PAD_X;
for (pod, size) in self.slots[tail_start..row_len]
.iter_mut()
.zip(sizes[tail_start..row_len].iter())
.rev()
{
right -= size.width;
pod.set_origin(Point::new(right, (TOOLBAR_HEIGHT - size.height) / 2.0));
right -= TOOLBAR_GAP;
}
if tail_start < row_len {
right += TOOLBAR_GAP;
}
let center_range = self.leading_count..tail_start;
let mut center_total: f64 =
sizes[center_range.clone()].iter().map(|s| s.width).sum();
if center_range.len() > 1 {
center_total += TOOLBAR_GAP * (center_range.len() - 1) as f64;
}
let available = (right - left).max(0.0);
let mut cx = left + ((available - center_total) / 2.0).max(0.0);
for (pod, size) in self.slots[center_range.clone()]
.iter_mut()
.zip(sizes[center_range].iter())
{
pod.set_origin(Point::new(cx, (TOOLBAR_HEIGHT - size.height) / 2.0));
cx += size.width + TOOLBAR_GAP;
}
width
}
};
self.pill_size = Size::new(pill_width, TOOLBAR_HEIGHT);
let (box_size, pill_origin) = if self.adjacent_fab {
let total = Size::new(
pill_width + TOOLBAR_TO_FAB_GAP + TOOLBAR_FAB_BASELINE,
TOOLBAR_FAB_MEDIUM,
);
let revealed = pill_width * progress.clamp(0.0, REVEAL_OVERSHOOT);
let x = match self.fab_position {
ToolbarFabPosition::End => pill_width - revealed,
ToolbarFabPosition::Start => total.width - pill_width,
};
(
total,
Point::new(x, (TOOLBAR_FAB_MEDIUM - TOOLBAR_HEIGHT) / 2.0),
)
} else {
(self.pill_size, Point::ZERO)
};
if self.adjacent_fab
&& let Some(index) = self.fab_index()
{
let fab_size = if self.fab_expands {
ToolbarMorph::size(progress)
} else {
TOOLBAR_FAB_BASELINE
};
let pod = &mut self.slots[index];
pod.layout_child(ctx, &BoxConstraints::tight(Size::new(fab_size, fab_size)));
let x = match self.fab_position {
ToolbarFabPosition::End => box_size.width - fab_size,
ToolbarFabPosition::Start => 0.0,
};
pod.set_origin(Point::new(x, (TOOLBAR_FAB_MEDIUM - fab_size) / 2.0));
}
let hidden = self.hidden();
let extent = finite_or_zero(self.exit_extent.unwrap_or(
if self.exit_direction.is_vertical() {
box_size.height + TOOLBAR_SCREEN_OFFSET
} else {
box_size.width + TOOLBAR_SCREEN_OFFSET
},
));
let shift = self.exit_direction.offset(hidden * extent.abs());
self.pill_origin = pill_origin + shift;
let pill_shift = self.pill_origin.to_vec2();
if pill_shift != Vec2::ZERO {
for pod in self.slots[..row_len].iter_mut() {
let origin = pod.origin();
pod.set_origin(origin + pill_shift);
}
}
if self.adjacent_fab
&& shift != Vec2::ZERO
&& let Some(index) = self.fab_index()
{
let origin = self.slots[index].origin();
self.slots[index].set_origin(origin + shift);
}
bc.constrain(box_size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let colors = ToolbarColors::resolve(theme, self.color_style);
let shadow = resolve_shadow(theme, self.elevation);
let radius = resolve_radius(
theme,
self.variant,
self.pill_size.width,
self.pill_size.height,
);
let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
let now = ctx.frame_time();
let moved = if reduce_motion {
let expand = self.expand.snap();
let hide = self.hide.snap();
expand || hide
} else {
let before = (self.expand.value, self.hide.value);
let expand = self.expand.advance(now);
let hide = self.hide.advance(now);
expand || hide || (self.expand.value, self.hide.value) != before
};
if moved {
ctx.request_layout();
}
let origin = ctx.origin();
let box_size = ctx.size();
let alpha = if self.morphs() {
ToolbarMorph::reveal_alpha(self.reveal) as f32
} else {
1.0
};
let (window_origin, window_size) = self.reveal_window(box_size);
let clipping =
window_origin != Point::ZERO || window_size != box_size || self.hidden() > 0.0;
if clipping {
scene.push_clip(
Point::new(origin.x + window_origin.x, origin.y + window_origin.y),
window_size,
);
}
if alpha < 1.0 {
scene.push_layer(origin, box_size, alpha);
}
let pill_origin = Point::new(origin.x + self.pill_origin.x, origin.y + self.pill_origin.y);
if let Some((blur, y_offset, shadow_color)) = shadow {
scene.draw_shadow(
Point::new(pill_origin.x, pill_origin.y + y_offset),
self.pill_size,
radius,
blur,
shadow_color,
);
}
scene.fill_rounded_rect(pill_origin, self.pill_size, radius, colors.container);
let row_len = self.row_len();
for pod in self.slots[..row_len].iter_mut() {
pod.paint_child(ctx, scene);
}
if alpha < 1.0 {
scene.pop_layer();
}
if clipping {
scene.pop_clip();
}
if self.adjacent_fab
&& let Some(index) = self.fab_index()
{
self.slots[index].paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
frust::authoring::route_event(&mut self.slots, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let slots = &self.slots;
let label = self.semantic_label.as_deref();
ctx.push_container(
Role::Toolbar,
|node| {
if let Some(label) = label {
node.set_label(label);
}
},
|ctx| {
for pod in slots {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(slots);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::BuildCtx;
use frust_widgets::test_support::leaf_any;
fn ctx(counter: &mut u64) -> BuildCtx<'_> {
BuildCtx::new(counter)
}
#[derive(Default)]
struct RRectRecorder {
rrects: Vec<(Point, Size, f64, Color)>,
shadows: Vec<(Point, Size)>,
clips: usize,
layers: Vec<f32>,
}
impl PaintScene for RRectRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn draw_shadow(&mut self, o: Point, s: Size, _r: f64, _sd: f64, _c: Color) {
self.shadows.push((o, s));
}
fn push_clip(&mut self, _o: Point, _s: Size) {
self.clips += 1;
}
fn push_layer(&mut self, _o: Point, _s: Size, alpha: f32) {
self.layers.push(alpha);
}
}
fn build(view: &ToolbarView<()>) -> ToolbarWidget {
let mut counter = 0u64;
View::<()>::build(view, &mut ctx(&mut counter))
}
fn layout(w: &mut ToolbarWidget, bc: &BoxConstraints) -> Size {
let mut lctx = LayoutCtx::new();
w.layout(&mut lctx, bc)
}
fn paint_at(w: &mut ToolbarWidget, millis: u64, theme: Option<&Theme>) -> bool {
let mut scene = RRectRecorder::default();
let mut pctx = PaintCtx::for_test(
Point::ZERO,
Size::new(400.0, TOOLBAR_FAB_MEDIUM),
FrameTime::from_nanos(millis * 1_000_000),
);
if let Some(theme) = theme {
pctx = pctx.with_theme(theme);
}
w.paint(&mut pctx, &mut scene);
pctx.needs_layout()
}
#[test]
fn the_ported_tokens_match_the_reference_table_exactly() {
assert_eq!(TOOLBAR_HEIGHT, 64.0);
assert_eq!(TOOLBAR_FLOATING_PAD, 8.0);
assert_eq!(TOOLBAR_DOCKED_PAD_X, 16.0);
assert_eq!(TOOLBAR_GAP, 4.0);
assert_eq!(TOOLBAR_TO_FAB_GAP, 8.0);
assert_eq!(TOOLBAR_SCREEN_OFFSET, 16.0);
assert_eq!(TOOLBAR_FAB_BASELINE, 56.0);
assert_eq!(TOOLBAR_FAB_MEDIUM, 80.0);
assert_eq!(TOOLBAR_MAX_INLINE_ACTIONS, 4);
assert_eq!(TOOLBAR_SETTLE_VELOCITY, 150.0);
}
#[test]
fn the_expand_spring_is_the_reference_expressive_spatial_fast_token() {
let token = crate::MaterialSpring::EXPRESSIVE_SPATIAL_FAST;
assert_eq!(TOOLBAR_EXPAND_SPRING.stiffness, token.stiffness);
assert_eq!(TOOLBAR_EXPAND_SPRING.damping_ratio, token.damping_ratio);
assert_eq!(TOOLBAR_EXPAND_SPRING.mass, 1.0);
}
#[test]
fn floating_layout_preserves_slot_order_and_applies_inset_pill_geometry() {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.center(vec![leaf_any(20.0, 20.0)])
.trailing(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0));
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
assert_eq!(size.height, TOOLBAR_FAB_MEDIUM);
assert!(size.width < 800.0);
assert!(w.slots[0].origin().x < w.slots[1].origin().x);
assert!(w.slots[1].origin().x < w.slots[2].origin().x);
assert_eq!(w.slots[0].origin().x, TOOLBAR_FLOATING_PAD);
let radius = resolve_radius(
None,
ToolbarVariant::Floating,
w.pill_size.width,
w.pill_size.height,
);
assert_eq!(radius, TOOLBAR_HEIGHT / 2.0);
}
#[test]
fn a_floating_toolbar_without_a_fab_hugs_its_own_content() {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.trailing(vec![leaf_any(24.0, 24.0)]);
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
assert_eq!(size.height, TOOLBAR_HEIGHT);
assert_eq!(
size.width,
TOOLBAR_FLOATING_PAD * 2.0 + 24.0 + TOOLBAR_GAP + 24.0,
"pad + item + gap + item + pad"
);
assert_eq!(
w.slots[1].origin().x + w.slots[1].size().width,
size.width - TOOLBAR_FLOATING_PAD
);
}
#[test]
fn docked_layout_spans_the_available_width_with_flat_geometry() {
let view: ToolbarView<()> = docked_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.trailing(vec![leaf_any(24.0, 24.0)]);
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
assert_eq!(size, Size::new(400.0, TOOLBAR_HEIGHT));
let radius = resolve_radius(None, ToolbarVariant::Docked, size.width, size.height);
assert_eq!(radius, 0.0, "docked variant is flat (square corners)");
assert_eq!(w.slots[0].origin().x, TOOLBAR_DOCKED_PAD_X);
assert_eq!(
w.slots[1].origin().x + w.slots[1].size().width,
400.0 - TOOLBAR_DOCKED_PAD_X
);
}
#[test]
fn docked_layout_centers_the_center_group_between_leading_and_trailing() {
let view: ToolbarView<()> = docked_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.center(vec![leaf_any(20.0, 20.0)])
.trailing(vec![leaf_any(24.0, 24.0)]);
let mut w = build(&view);
layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
assert!(w.slots[0].origin().x < w.slots[1].origin().x);
assert!(w.slots[1].origin().x < w.slots[2].origin().x);
let leading_end = w.slots[0].origin().x + w.slots[0].size().width;
let trailing_start = w.slots[2].origin().x;
let center_start = w.slots[1].origin().x;
let center_end = center_start + w.slots[1].size().width;
let left_gap = center_start - leading_end;
let right_gap = trailing_start - center_end;
assert!(
(left_gap - right_gap).abs() < 0.01,
"the center group is centered in the remaining space \
(left_gap={left_gap}, right_gap={right_gap})"
);
}
#[test]
fn docked_layout_places_fab_flush_against_the_trailing_edge_after_trailing() {
let view: ToolbarView<()> = docked_toolbar()
.trailing(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0));
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
assert_eq!(size, Size::new(400.0, TOOLBAR_HEIGHT));
assert!(w.slots[0].origin().x < w.slots[1].origin().x);
assert_eq!(
w.slots[1].origin().x + w.slots[1].size().width,
400.0 - TOOLBAR_DOCKED_PAD_X,
"the fab slot sits flush against the trailing edge"
);
}
#[test]
fn the_fab_morphs_from_80_collapsed_to_56_expanded() {
assert_eq!(ToolbarMorph::size(0.0), TOOLBAR_FAB_MEDIUM);
assert_eq!(ToolbarMorph::size(1.0), TOOLBAR_FAB_BASELINE);
assert_eq!(ToolbarMorph::size(0.5), 68.0);
assert!(ToolbarMorph::size(1.2) < TOOLBAR_FAB_BASELINE);
assert_eq!(ToolbarMorph::size(100.0), 0.0);
assert_eq!(ToolbarMorph::size(f64::NAN), TOOLBAR_FAB_MEDIUM);
}
#[test]
fn a_collapsed_bar_lays_its_fab_out_at_the_80dp_end_and_hides_the_pill() {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0))
.expanded(false);
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
let fab = w.slots[w.fab_index().expect("a fab pod")].size();
assert_eq!(fab, Size::new(TOOLBAR_FAB_MEDIUM, TOOLBAR_FAB_MEDIUM));
assert_eq!(size.height, TOOLBAR_FAB_MEDIUM);
assert_eq!(w.reveal, 0.0, "collapsed: nothing of the pill is revealed");
let (window_origin, _) = w.reveal_window(size);
assert!(window_origin.x >= w.pill_origin.x + w.pill_size.width);
}
#[test]
fn a_non_finite_exit_extent_prop_reaches_layout_and_paint_without_panicking() {
for extent in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0))
.expanded(false)
.exit_extent(Some(extent));
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
assert!(w.pill_origin.x.is_finite());
assert!(w.pill_origin.y.is_finite());
let (window_origin, window_size) = w.reveal_window(size);
assert!(window_origin.x.is_finite());
assert!(window_size.width.is_finite());
paint_at(&mut w, 0, None);
}
}
#[test]
fn an_expanded_bar_lays_its_fab_out_at_the_56dp_end_beside_the_pill() {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0));
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
let index = w.fab_index().expect("a fab pod");
assert_eq!(
w.slots[index].size(),
Size::new(TOOLBAR_FAB_BASELINE, TOOLBAR_FAB_BASELINE)
);
assert_eq!(
size.width,
w.pill_size.width + TOOLBAR_TO_FAB_GAP + TOOLBAR_FAB_BASELINE
);
assert_eq!(w.pill_origin.x, 0.0);
assert_eq!(
w.slots[index].origin().x + TOOLBAR_FAB_BASELINE,
size.width,
"the fab hugs the trailing edge"
);
assert_eq!(w.reveal, 1.0);
}
#[test]
fn pinning_fab_expands_toolbar_off_keeps_the_fab_at_the_baseline_square() {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0))
.fab_expands_toolbar(false)
.expanded(false);
let mut w = build(&view);
layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
let index = w.fab_index().expect("a fab pod");
assert_eq!(
w.slots[index].size(),
Size::new(TOOLBAR_FAB_BASELINE, TOOLBAR_FAB_BASELINE)
);
assert_eq!(w.reveal, 1.0, "the pill stays open");
}
#[test]
fn a_start_positioned_fab_leads_the_pill() {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0))
.fab_position(ToolbarFabPosition::Start);
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
let index = w.fab_index().expect("a fab pod");
assert_eq!(w.slots[index].origin().x, 0.0);
assert_eq!(w.pill_origin.x, size.width - w.pill_size.width);
}
#[test]
fn the_expand_ramp_settles_onto_the_target_and_stops_requesting_layout() {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0))
.expanded(false);
let next: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0));
let mut counter = 0u64;
let mut w = View::<()>::build(&view, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
assert!(!paint_at(&mut w, 0, None), "a resting bar asks for nothing");
let flags = View::<()>::rebuild(&next, &view, &mut w, &mut ctx(&mut counter));
assert!(flags.needs_layout(), "a retarget is a layout change");
let mut frames = 0u64;
loop {
layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
let still = paint_at(&mut w, frames * 16, None);
frames += 1;
if !still {
break;
}
assert!(frames < 400, "the ramp settles well inside 400 frames");
}
layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
assert_eq!(w.expand.value, 1.0, "the settled ramp lands on the target");
assert_eq!(
w.slots[w.fab_index().expect("a fab pod")].size().width,
TOOLBAR_FAB_BASELINE
);
}
#[test]
fn reduce_motion_snaps_both_ramps_and_requests_exactly_one_relayout() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0))
.expanded(false);
let next: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0));
let mut counter = 0u64;
let mut w = View::<()>::build(&view, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
View::<()>::rebuild(&next, &view, &mut w, &mut ctx(&mut counter));
assert!(
paint_at(&mut w, 16, Some(&theme)),
"the snap moved a layout value, so it asks for one relayout"
);
assert_eq!(w.expand.value, 1.0, "snapped");
assert!(
!paint_at(&mut w, 32, Some(&theme)),
"and never asks again once there is nothing left to move"
);
}
#[test]
fn the_measured_exit_limit_is_the_bar_extent_plus_the_screen_offset() {
let hide = ToolbarScrollHide::for_bar(TOOLBAR_HEIGHT);
assert_eq!(
hide.offset_limit(),
-(TOOLBAR_HEIGHT + TOOLBAR_SCREEN_OFFSET)
);
assert_eq!(hide.collapsed_fraction(), 0.0);
assert!(!hide.is_hidden());
assert_eq!(ToolbarScrollHide::new(-40.0).offset_limit(), -40.0);
}
#[test]
fn a_non_finite_construction_parameter_degrades_instead_of_panicking() {
for degenerate in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut hide = ToolbarScrollHide::new(degenerate);
assert_eq!(hide.offset_limit(), 0.0, "degrades to a resting limit");
hide.scroll(40.0);
hide.scroll(-1_000.0);
hide.settle(1_000.0);
hide.toggle();
assert!(hide.offset().is_finite());
let mut hide = ToolbarScrollHide::for_bar(degenerate);
assert_eq!(
hide.offset_limit(),
0.0,
"for_bar degrades the same way, delegating to new"
);
hide.scroll(degenerate);
hide.scroll(40.0);
hide.toggle();
assert!(hide.offset().is_finite());
}
}
#[test]
fn scrolling_accumulates_into_a_clamped_hidden_fraction() {
let mut hide = ToolbarScrollHide::new(80.0);
hide.scroll(40.0);
assert_eq!(hide.offset(), -40.0);
assert_eq!(hide.collapsed_fraction(), 0.5);
hide.scroll(-15.0);
assert_eq!(hide.offset(), -25.0);
hide.scroll(1_000.0);
assert_eq!(hide.offset(), -80.0);
assert!(hide.is_hidden());
hide.scroll(f64::NAN);
assert_eq!(hide.offset(), -80.0);
hide.scroll(-1_000.0);
assert_eq!(hide.offset(), 0.0);
}
#[test]
fn a_settle_commits_to_the_fling_direction_past_the_velocity_threshold() {
let mut hide = ToolbarScrollHide::new(80.0);
hide.scroll(10.0);
assert!(!hide.settle(TOOLBAR_SETTLE_VELOCITY + 1.0));
assert!(hide.is_hidden());
let mut hide = ToolbarScrollHide::new(80.0);
hide.scroll(70.0);
assert!(hide.settle(-(TOOLBAR_SETTLE_VELOCITY + 1.0)));
assert_eq!(hide.offset(), 0.0);
let mut hide = ToolbarScrollHide::new(80.0);
hide.scroll(30.0);
assert!(hide.settle(10.0));
let mut hide = ToolbarScrollHide::new(80.0);
hide.scroll(50.0);
assert!(!hide.settle(10.0));
let mut hide = ToolbarScrollHide::new(80.0);
assert!(hide.settle(1_000.0));
assert_eq!(hide.offset(), 0.0);
}
#[test]
fn toggle_flips_about_the_halfway_point() {
let mut hide = ToolbarScrollHide::new(80.0);
hide.toggle();
assert!(hide.is_hidden());
hide.toggle();
assert_eq!(hide.offset(), 0.0);
}
#[test]
fn a_pinned_hidden_fraction_translates_every_slot_along_the_exit_direction() {
let visible: ToolbarView<()> = docked_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
let mut w = build(&visible);
layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
let resting = w.slots[0].origin();
let hidden: ToolbarView<()> = docked_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.hidden_fraction(Some(1.0));
let mut counter = 0u64;
let flags = View::<()>::rebuild(&hidden, &visible, &mut w, &mut ctx(&mut counter));
assert!(flags.needs_layout(), "the hide travel is a layout value");
let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
let travel = size.height + TOOLBAR_SCREEN_OFFSET;
assert_eq!(w.slots[0].origin().y, resting.y + travel);
assert_eq!(w.pill_origin.y, travel);
assert!(w.slots[0].origin().y >= size.height);
}
#[test]
fn every_exit_direction_translates_on_its_own_axis() {
for (direction, expected) in [
(ToolbarExitDirection::Top, Vec2::new(0.0, -10.0)),
(ToolbarExitDirection::Bottom, Vec2::new(0.0, 10.0)),
(ToolbarExitDirection::Start, Vec2::new(-10.0, 0.0)),
(ToolbarExitDirection::End, Vec2::new(10.0, 0.0)),
] {
assert_eq!(direction.offset(10.0), expected);
}
assert!(ToolbarExitDirection::Top.is_vertical());
assert!(!ToolbarExitDirection::End.is_vertical());
}
#[test]
fn an_explicit_exit_extent_overrides_the_measured_one() {
let view: ToolbarView<()> = docked_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.hidden_fraction(Some(1.0))
.exit_extent(Some(20.0));
let mut w = build(&view);
layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
assert_eq!(w.pill_origin.y, 20.0);
}
#[test]
fn the_visible_prop_springs_the_hide_travel_and_settles_hidden() {
let visible: ToolbarView<()> = docked_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
let mut counter = 0u64;
let mut w = View::<()>::build(&visible, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
let hidden: ToolbarView<()> = docked_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.visible(false);
View::<()>::rebuild(&hidden, &visible, &mut w, &mut ctx(&mut counter));
let mut frames = 0u64;
loop {
layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
let still = paint_at(&mut w, frames * 16, None);
frames += 1;
if !still {
break;
}
assert!(frames < 400, "the hide ramp settles well inside 400 frames");
}
assert_eq!(w.hidden(), 1.0, "the settled ramp lands fully hidden");
}
#[test]
fn the_capacity_rule_matches_the_reference_partition() {
let split = partition_actions(3, TOOLBAR_MAX_INLINE_ACTIONS, None);
assert_eq!(split.inline, vec![0, 1, 2]);
assert!(split.overflow.is_empty());
let split = partition_actions(7, TOOLBAR_MAX_INLINE_ACTIONS, None);
assert_eq!(split.inline, vec![0, 1, 2, 3]);
assert_eq!(split.overflow, vec![4, 5, 6]);
let split = partition_actions(7, TOOLBAR_MAX_INLINE_ACTIONS, Some(0));
assert_eq!(split.inline, vec![0, 1, 2, 3]);
assert_eq!(split.overflow, vec![4, 5, 6]);
let split = partition_actions(7, TOOLBAR_MAX_INLINE_ACTIONS, Some(6));
assert_eq!(split.inline, vec![0, 1, 2, 6]);
assert_eq!(split.overflow, vec![3, 4, 5]);
let split = partition_actions(3, 0, Some(1));
assert_eq!(split.inline, vec![1]);
assert_eq!(split.overflow, vec![0, 2]);
let split = partition_actions(2, 1, Some(9));
assert_eq!(split.inline, vec![0]);
assert_eq!(split.overflow, vec![1]);
}
fn action(label: &str) -> ToolbarAction<Vec<String>> {
let owned = label.to_owned();
toolbar_action::<Vec<String>, _>(crate::icons::MORE_HORIZ, move |log: &mut Vec<String>| {
log.push(owned.clone())
})
.label(label)
}
#[test]
fn overflowed_actions_become_menu_rows_routing_back_to_their_own_action() {
let view: ToolbarView<Vec<String>> = floating_toolbar()
.actions(vec![
action("one"),
action("two"),
action("three"),
action("four"),
action("five"),
action("six"),
])
.max_inline_actions(4);
let entries = view.overflow_entries();
assert_eq!(entries.len(), 2, "two actions past the budget");
let labels: Vec<String> = entries
.iter()
.map(|(node, _)| match node {
MenuNode::Entry(entry) => entry.label().to_owned(),
_ => unreachable!("an overflowed action is always a plain entry"),
})
.collect();
assert_eq!(labels, vec!["five".to_owned(), "six".to_owned()]);
let mut log: Vec<String> = Vec::new();
entries[1].1(&mut log);
assert_eq!(log, vec!["six".to_owned()]);
}
#[test]
fn an_overflowed_row_falls_back_through_the_reference_label_chain() {
let bare = toolbar_action::<(), _>(crate::icons::MORE_VERT, |_| {});
assert_eq!(bare.menu_label(3), "Action 4");
assert_eq!(
toolbar_action::<(), _>(crate::icons::MORE_VERT, |_| {})
.tooltip("Tip")
.menu_label(0),
"Tip"
);
assert_eq!(
toolbar_action::<(), _>(crate::icons::MORE_VERT, |_| {})
.tooltip("Tip")
.label("Label")
.menu_label(0),
"Label"
);
assert_eq!(
toolbar_action::<(), _>(crate::icons::MORE_VERT, |_| {})
.semantic_label("Named")
.menu_label(0),
"Named"
);
}
#[test]
fn the_overflow_trigger_joins_the_row_only_once_something_overflows() {
let fits: ToolbarView<Vec<String>> =
floating_toolbar().actions(vec![action("one"), action("two")]);
let mut w = build_any(&fits);
assert_eq!(w.center_count, 2, "no trigger while everything fits");
let spills: ToolbarView<Vec<String>> = floating_toolbar().actions(vec![
action("one"),
action("two"),
action("three"),
action("four"),
action("five"),
]);
w = build_any(&spills);
assert_eq!(w.center_count, 5, "four inline actions plus the trigger");
assert_eq!(w.slots.len(), 5);
}
fn build_any<State: 'static>(view: &ToolbarView<State>) -> ToolbarWidget {
let mut counter = 0u64;
View::<State>::build(view, &mut ctx(&mut counter))
}
#[test]
fn unthemed_paint_uses_the_standard_fallback_container() {
let view: ToolbarView<()> = docked_toolbar();
let mut w = build(&view);
layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
let mut scene = RRectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, TOOLBAR_HEIGHT));
w.paint(&mut pctx, &mut scene);
assert_eq!(scene.rrects[0].1, Size::new(300.0, TOOLBAR_HEIGHT));
assert_eq!(scene.rrects[0].3, SURFACE_CONTAINER);
assert!(
scene.shadows.is_empty(),
"a bar with no fab rests at `elevationNone`"
);
}
#[test]
fn themed_paint_resolves_surface_container() {
let theme = crate::baseline();
let view: ToolbarView<()> = floating_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
let mut scene = RRectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
w.paint(&mut pctx, &mut scene);
assert_eq!(scene.rrects[0].3, theme.scheme().surface_container);
assert_eq!(
scene.rrects[0].2,
ShapeScale::resolve(theme.shape.full, size.width, size.height)
);
}
#[test]
fn the_vibrant_style_resolves_the_reference_primary_container_pair() {
let theme = crate::baseline();
let standard = ToolbarColors::resolve(Some(&theme), ToolbarColorStyle::Standard);
assert_eq!(standard.container, theme.scheme().surface_container);
assert_eq!(standard.content, theme.scheme().on_surface);
assert_eq!(standard.fab_container, theme.scheme().primary_container);
assert_eq!(standard.fab_content, theme.scheme().on_primary_container);
let vibrant = ToolbarColors::resolve(Some(&theme), ToolbarColorStyle::Vibrant);
assert_eq!(vibrant.container, theme.scheme().primary_container);
assert_eq!(vibrant.content, theme.scheme().on_primary_container);
assert_eq!(vibrant.fab_container, theme.scheme().tertiary_container);
assert_eq!(vibrant.fab_content, theme.scheme().on_tertiary_container);
let unthemed = ToolbarColors::resolve(None, ToolbarColorStyle::Vibrant);
assert_eq!(unthemed.container, PRIMARY_CONTAINER);
assert_eq!(unthemed.fab_container, TERTIARY_CONTAINER);
let view: ToolbarView<()> = docked_toolbar().color_style(ToolbarColorStyle::Vibrant);
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
let mut scene = RRectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
w.paint(&mut pctx, &mut scene);
assert_eq!(scene.rrects[0].3, theme.scheme().primary_container);
}
#[test]
fn the_elevation_pair_follows_the_reference_rule_with_an_explicit_override() {
let plain: ToolbarView<()> = floating_toolbar();
assert_eq!(plain.resolved_elevation(), ToolbarElevation::None);
assert!(resolve_shadow(None, ToolbarElevation::None).is_none());
let with_fab: ToolbarView<()> = floating_toolbar().fab(leaf_any(40.0, 40.0));
assert_eq!(with_fab.resolved_elevation(), ToolbarElevation::Level1);
let (blur, y, _) =
resolve_shadow(None, ToolbarElevation::Level1).expect("a level-1 shadow");
assert_eq!((blur, y), (LEVEL1_SHADOW_BLUR, LEVEL1_SHADOW_Y_OFFSET));
let pinned: ToolbarView<()> = floating_toolbar()
.fab(leaf_any(40.0, 40.0))
.elevation(ToolbarElevation::Level2);
assert_eq!(pinned.resolved_elevation(), ToolbarElevation::Level2);
let (blur, y, _) =
resolve_shadow(None, ToolbarElevation::Level2).expect("a level-2 shadow");
assert_eq!((blur, y), (LEVEL2_SHADOW_BLUR, LEVEL2_SHADOW_Y_OFFSET));
let theme = crate::baseline();
let (blur, y, _) =
resolve_shadow(Some(&theme), ToolbarElevation::Level1).expect("a themed shadow");
let spec = theme.elevation.level1.shadow(theme.brightness);
assert_eq!((blur, y), (spec.blur_std_dev, spec.y_offset));
}
#[test]
fn a_revealing_pill_is_clipped_and_faded_while_the_fab_stays_opaque() {
let view: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0))
.expanded(false);
let mut w = build(&view);
layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
let mut scene = RRectRecorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, TOOLBAR_FAB_MEDIUM));
w.paint(&mut pctx, &mut scene);
assert_eq!(scene.clips, 1, "the pill paints inside a reveal clip");
assert_eq!(
scene.layers,
vec![0.0],
"collapsed, the pill is fully faded out"
);
assert_eq!(ToolbarMorph::reveal_alpha(0.0), 0.0);
assert_eq!(ToolbarMorph::reveal_alpha(0.5), 0.0);
assert_eq!(ToolbarMorph::reveal_alpha(1.0), 1.0);
assert!(ToolbarMorph::reveal_alpha(0.75) > 0.0);
}
#[test]
fn semantics_node_is_toolbar_forwarding_every_slot() {
fn logic(_state: &mut ()) -> ToolbarView<()> {
docked_toolbar()
.semantic_label("Editing")
.leading(vec![frust::authoring::any::<(), _>(frust::text("Leading"))])
.trailing(vec![frust::authoring::any::<(), _>(frust::text(
"Trailing",
))])
}
let mut root: frust_core::RenderRoot<(), ToolbarView<()>> = 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, TOOLBAR_HEIGHT),
&mut tcx as &mut dyn std::any::Any,
);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Toolbar)
.expect("a Toolbar node is contributed");
assert_eq!(node.children().len(), 2, "leading + trailing forwarded");
assert_eq!(node.label(), Some("Editing"));
}
#[test]
fn rebuild_adopts_new_slot_counts() {
let mut counter = 0u64;
let prev: ToolbarView<()> = floating_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
assert_eq!(w.leading_count, 1);
let next: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.trailing(vec![leaf_any(24.0, 24.0), leaf_any(24.0, 24.0)]);
let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
assert!(flags.needs_layout());
assert_eq!(w.trailing_count, 2);
assert_eq!(w.slots.len(), 3);
}
#[test]
fn attaching_a_fab_switches_a_floating_bar_onto_the_adjacent_layout() {
let mut counter = 0u64;
let prev: ToolbarView<()> = floating_toolbar().leading(vec![leaf_any(24.0, 24.0)]);
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
assert_eq!(size.height, TOOLBAR_HEIGHT);
let next: ToolbarView<()> = floating_toolbar()
.leading(vec![leaf_any(24.0, 24.0)])
.fab(leaf_any(40.0, 40.0));
let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
assert!(flags.needs_layout());
assert!(w.adjacent_fab);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(800.0, 200.0)));
assert_eq!(size.height, TOOLBAR_FAB_MEDIUM);
}
}