use std::rc::Rc;
use std::time::Duration;
use frust::authoring::Role;
use frust::authoring::text::{FontWeight, LineHeight};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, SemanticsCtx, View,
Widget,
};
use frust::{AnimationController, FrameTime, Spring, SpringDesc, Theme, text};
use kurbo::{Affine, Point, Rect, Size, Vec2};
use peniko::Color;
use frust::authoring::{ThemeTextColor, ThemeTextType};
use super::press::presses;
use crate::tokens::MaterialSpring;
pub const HEIGHT_SMALL: f64 = 64.0;
pub const HEIGHT_MEDIUM: f64 = 80.0;
pub const INDICATOR_W: f64 = 64.0;
pub const INDICATOR_H: f64 = 32.0;
pub const UNDERLINE_THICKNESS: f64 = 3.0;
const LABEL_GAP: f64 = 4.0;
const LABEL_SIZE: f32 = 12.0;
const LABEL_LINE_HEIGHT: f32 = 16.0;
const LABEL_LETTER_SPACING: f32 = 0.5;
const LABEL_WEIGHT: FontWeight = FontWeight::BOLD;
const CONTAINER: Color = Color::from_rgb8(0xF3, 0xED, 0xF7);
const INDICATOR: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
pub const LEAD_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
damping_ratio: 0.45,
};
pub const TRAIL_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
damping_ratio: 0.55,
};
const PILL_REST_EPSILON: f64 = 0.01;
const GEOMETRY_EPSILON: f64 = 0.5;
fn geometry_unchanged(a: Rect, b: Rect) -> bool {
(a.x0 - b.x0).abs() < GEOMETRY_EPSILON
&& (a.x1 - b.x1).abs() < GEOMETRY_EPSILON
&& (a.y0 - b.y0).abs() < GEOMETRY_EPSILON
&& (a.y1 - b.y1).abs() < GEOMETRY_EPSILON
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct LiquidEdge {
desc: SpringDesc,
target: f64,
flight: Option<Spring>,
elapsed: f64,
position: f64,
velocity: f64,
}
impl LiquidEdge {
pub(crate) const fn new(desc: SpringDesc) -> Self {
LiquidEdge {
desc,
target: 0.0,
flight: None,
elapsed: 0.0,
position: 0.0,
velocity: 0.0,
}
}
pub(crate) fn snap(&mut self, to: f64) {
self.target = to;
self.position = to;
self.velocity = 0.0;
self.flight = None;
self.elapsed = 0.0;
}
pub(crate) fn retarget(&mut self, to: f64) {
self.flight = Some(Spring::new(self.desc, self.position - to, self.velocity));
self.target = to;
self.elapsed = 0.0;
}
pub(crate) fn advance(&mut self, dt: f64) -> bool {
let Some(spring) = self.flight else {
return false;
};
self.elapsed += dt;
if spring.is_at_rest(self.elapsed, PILL_REST_EPSILON) {
self.settle();
return false;
}
self.position = self.target + spring.position(self.elapsed);
self.velocity = spring.velocity(self.elapsed);
true
}
pub(crate) fn settle(&mut self) {
self.position = self.target;
self.velocity = 0.0;
self.flight = None;
self.elapsed = 0.0;
}
pub(crate) fn is_animating(&self) -> bool {
self.flight.is_some()
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct LiquidIndicator {
lead: LiquidEdge,
trail: LiquidEdge,
last_time: Option<FrameTime>,
rest: Rect,
ready: bool,
}
impl LiquidIndicator {
pub(crate) const fn new() -> Self {
LiquidIndicator {
lead: LiquidEdge::new(LEAD_SPRING),
trail: LiquidEdge::new(TRAIL_SPRING),
last_time: None,
rest: Rect::ZERO,
ready: false,
}
}
pub(crate) fn sync(&mut self, rest: Rect, travel: bool) {
let center = rest.center().x;
if !self.ready {
self.rest = rest;
self.lead.snap(center);
self.trail.snap(center);
self.last_time = None;
self.ready = true;
return;
}
if travel {
self.rest = rest;
if (self.lead.target - center).abs() < GEOMETRY_EPSILON && !self.is_animating() {
return;
}
self.lead.retarget(center);
self.trail.retarget(center);
self.last_time = None;
return;
}
if geometry_unchanged(self.rest, rest) {
return;
}
self.rest = rest;
self.lead.snap(center);
self.trail.snap(center);
self.last_time = None;
}
pub(crate) fn advance(&mut self, now: FrameTime) -> bool {
let dt = match self.last_time {
Some(last) => now.saturating_sub(last).as_secs_f64(),
None => 0.0,
};
self.last_time = Some(now);
let lead = self.lead.advance(dt);
let trail = self.trail.advance(dt);
lead || trail
}
pub(crate) fn settle(&mut self) {
self.lead.settle();
self.trail.settle();
}
pub(crate) fn is_animating(&self) -> bool {
self.lead.is_animating() || self.trail.is_animating()
}
pub(crate) fn pill_rect(&self) -> Option<Rect> {
if !self.ready {
return None;
}
let base = self.rest.width();
let min = self.lead.position.min(self.trail.position);
let max = self.lead.position.max(self.trail.position);
let x0 = min - base / 2.0;
Some(Rect::new(
x0,
self.rest.y0,
x0 + (max - min) + base,
self.rest.y1,
))
}
pub(crate) fn radius(&self) -> f64 {
self.rest.height().min(self.rest.width()) / 2.0
}
}
const ICON_POP_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
damping_ratio: 0.5,
};
const ICON_POP_FROM: f64 = 0.98;
const ICON_POP_LAUNCH_VELOCITY: f64 = 1e-3;
const ICON_POP_EPSILON: f64 = 1e-4;
fn settled_pop() -> AnimationController {
let mut pop = AnimationController::new(Duration::ZERO);
pop.animate_to(1.0);
pop.advance(FrameTime::ZERO); pop
}
fn launched_pop() -> AnimationController {
let mut pop = AnimationController::new(Duration::ZERO);
pop.fling(ICON_POP_LAUNCH_VELOCITY, ICON_POP_SPRING);
pop
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NavBarSize {
Small,
#[default]
Medium,
}
impl NavBarSize {
pub fn height(self) -> f64 {
match self {
NavBarSize::Small => HEIGHT_SMALL,
NavBarSize::Medium => HEIGHT_MEDIUM,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NavBarLabelBehavior {
#[default]
AlwaysShow,
OnlySelected,
AlwaysHide,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NavBarIndicatorStyle {
#[default]
Pill,
Underline,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum NavBadge {
Dot,
Count(u32),
}
fn resolve_colors(theme: Option<&Theme>) -> (Color, Color) {
match theme {
Some(theme) => {
let scheme = theme.scheme();
(scheme.surface_container, scheme.secondary_container)
}
None => (CONTAINER, INDICATOR),
}
}
fn label_view<State: 'static>(label: String, role: ThemeTextColor) -> AnyView<State> {
frust::authoring::any::<State, _>(
text(label)
.size(LABEL_SIZE)
.weight(LABEL_WEIGHT)
.letter_spacing(LABEL_LETTER_SPACING)
.line_height(LineHeight::Absolute(LABEL_LINE_HEIGHT))
.themed_role(role)
.themed_family(ThemeTextType::LabelMediumEmphasized),
)
}
fn label_role(selected: bool) -> ThemeTextColor {
if selected {
ThemeTextColor::OnSurface
} else {
ThemeTextColor::OnSurfaceVariant
}
}
fn label_shown(behavior: NavBarLabelBehavior, selected: bool) -> bool {
match behavior {
NavBarLabelBehavior::AlwaysShow => true,
NavBarLabelBehavior::OnlySelected => selected,
NavBarLabelBehavior::AlwaysHide => false,
}
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
pub struct NavItem<State: 'static> {
icon: Option<AnyView<State>>,
label: String,
badge: Option<NavBadge>,
}
pub fn nav_item<State: 'static>(label: impl Into<String>) -> NavItem<State> {
NavItem {
icon: None,
label: label.into(),
badge: None,
}
}
impl<State: 'static> NavItem<State> {
pub fn icon(mut self, icon: AnyView<State>) -> Self {
self.icon = Some(icon);
self
}
pub fn badge_dot(mut self) -> Self {
self.badge = Some(NavBadge::Dot);
self
}
pub fn badge_count(mut self, count: u32) -> Self {
self.badge = Some(NavBadge::Count(count));
self
}
fn compose_badge(mut self) -> Self {
let Some(kind) = self.badge else {
return self;
};
let Some(icon) = self.icon.take() else {
return self;
};
let badged = crate::badge::badge::<State, _>(icon);
let badged = match kind {
NavBadge::Dot => badged.dot(),
NavBadge::Count(count) => badged.count(count),
};
self.icon = Some(frust::authoring::any::<State, _>(badged));
self
}
}
type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
pub struct NavigationBarView<State: 'static> {
items: Vec<NavItem<State>>,
selected: usize,
on_select: OnSelect<State>,
size: NavBarSize,
label_behavior: NavBarLabelBehavior,
indicator_style: NavBarIndicatorStyle,
safe_area: bool,
}
pub fn navigation_bar<State: 'static, F: Fn(&mut State, usize) + 'static>(
items: Vec<NavItem<State>>,
selected: usize,
on_select: F,
) -> NavigationBarView<State> {
NavigationBarView {
items: items
.into_iter()
.map(NavItem::compose_badge)
.collect::<Vec<_>>(),
selected,
on_select: Rc::new(on_select),
size: NavBarSize::default(),
label_behavior: NavBarLabelBehavior::default(),
indicator_style: NavBarIndicatorStyle::default(),
safe_area: true,
}
}
#[allow(non_snake_case)]
pub fn NavigationBar<State: 'static, F: Fn(&mut State, usize) + 'static>(
items: Vec<NavItem<State>>,
selected: usize,
on_select: F,
) -> NavigationBarView<State> {
navigation_bar(items, selected, on_select)
}
impl<State: 'static> NavigationBarView<State> {
pub fn size(mut self, size: NavBarSize) -> Self {
self.size = size;
self
}
pub fn label_behavior(mut self, behavior: NavBarLabelBehavior) -> Self {
self.label_behavior = behavior;
self
}
pub fn indicator_style(mut self, style: NavBarIndicatorStyle) -> Self {
self.indicator_style = style;
self
}
pub fn safe_area(mut self, enabled: bool) -> Self {
self.safe_area = enabled;
self
}
}
fn item_on_select<State: 'static>(
on_select: &OnSelect<State>,
idx: usize,
) -> frust::authoring::ErasedCallback {
let callback = on_select.clone();
Box::new(move |ctx: &mut EventCtx| {
let state = ctx.state_mut::<State>();
callback(state, idx);
})
}
struct NavItemWidget {
icon: Option<ChildPod>,
label: Option<ChildPod>,
label_text: String,
selected: bool,
icon_pop: AnimationController,
on_select: frust::authoring::ErasedCallback,
captured: bool,
indicator_rect: Rect,
}
fn build_item<State: 'static>(
item: &NavItem<State>,
selected: bool,
behavior: NavBarLabelBehavior,
on_select: &OnSelect<State>,
idx: usize,
ctx: &mut BuildCtx<'_>,
) -> ChildPod {
let icon = item
.icon
.as_ref()
.map(|icon| frust::authoring::build_child(icon, ctx));
let label = label_shown(behavior, selected).then(|| {
let view = label_view::<State>(item.label.clone(), label_role(selected));
frust::authoring::build_child(&view, ctx)
});
let widget = NavItemWidget {
icon,
label,
label_text: item.label.clone(),
selected,
icon_pop: settled_pop(),
on_select: item_on_select::<State>(on_select, idx),
captured: false,
indicator_rect: Rect::ZERO,
};
ChildPod::new(Box::new(widget))
}
impl Widget for NavItemWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
let height = if bc.max().height.is_finite() {
bc.max().height
} else {
NavBarSize::default().height()
};
let label_size = self.label.as_mut().map(|label| {
label.layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)))
});
let content_h = INDICATOR_H + label_size.map_or(0.0, |size| LABEL_GAP + size.height);
let top = ((height - content_h) / 2.0).max(0.0);
let pill_w = INDICATOR_W.min(width);
let pill_x = (width - pill_w) / 2.0;
self.indicator_rect = Rect::new(pill_x, top, pill_x + pill_w, top + INDICATOR_H);
if let Some(icon) = self.icon.as_mut() {
let icon_size =
icon.layout_child(ctx, &BoxConstraints::loose(Size::new(pill_w, INDICATOR_H)));
icon.set_origin(Point::new(
pill_x + (pill_w - icon_size.width) / 2.0,
top + (INDICATOR_H - icon_size.height) / 2.0,
));
}
if let (Some(label), Some(size)) = (self.label.as_mut(), label_size) {
label.set_origin(Point::new(
(width - size.width) / 2.0,
top + INDICATOR_H + LABEL_GAP,
));
}
bc.constrain(Size::new(width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
if reduce_motion {
if self.icon_pop.is_animating() {
self.icon_pop = settled_pop();
}
} else if self.icon_pop.advance(ctx.frame_time()) {
ctx.request_frame();
}
if let Some(icon) = self.icon.as_mut() {
let scale = ICON_POP_FROM + (1.0 - ICON_POP_FROM) * self.icon_pop.value();
let popped = (scale - 1.0).abs() > ICON_POP_EPSILON;
if popped {
let center = (ctx.origin() + icon.origin().to_vec2()).to_vec2()
+ icon.size().to_vec2() / 2.0;
scene.push_transform(
Affine::translate(center) * Affine::scale(scale) * Affine::translate(-center),
);
}
icon.paint_child(ctx, scene);
if popped {
scene.pop_transform();
}
}
if let Some(label) = self.label.as_mut() {
label.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.captured = true;
ctx.capture_pointer();
EventResult::Handled
}
PointerPhase::Move => {
if !self.captured {
return EventResult::Ignored;
}
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
if inside(p.position, ctx.size()) {
(self.on_select)(ctx);
}
self.captured = false;
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.captured = false;
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::Tab, |node| {
node.set_label(self.label_text.as_str());
node.set_selected(self.selected);
});
}
frust::authoring::visit_children!(icon, label);
}
fn cancel_item(pod: &mut ChildPod) {
let mut dummy_state = ();
let mut ctx = EventCtx::new(&mut dummy_state, pod.origin(), pod.size());
let cancel = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Cancel,
position: Point::ZERO,
button: PointerButton::Primary,
});
pod.event_child(&mut ctx, &cancel);
}
fn teardown_item<State: 'static>(
item: &NavItem<State>,
selected: bool,
behavior: NavBarLabelBehavior,
widget: &mut NavItemWidget,
ctx: &mut BuildCtx<'_>,
) {
if let (Some(icon_view), Some(icon_pod)) = (&item.icon, widget.icon.as_mut()) {
frust::authoring::teardown_child(icon_view, icon_pod, ctx);
}
if let (true, Some(label_pod)) = (label_shown(behavior, selected), widget.label.as_mut()) {
let view = label_view::<State>(item.label.clone(), label_role(selected));
frust::authoring::teardown_child(&view, label_pod, ctx);
}
}
pub struct NavigationBarWidget {
items: Vec<ChildPod>,
selected: usize,
size: NavBarSize,
safe_area: bool,
bottom_inset: f64,
indicator_style: NavBarIndicatorStyle,
indicator: LiquidIndicator,
selection_moved: bool,
}
impl<State: 'static> View<State> for NavigationBarView<State> {
type Element = NavigationBarWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> NavigationBarWidget {
let mut items = Vec::with_capacity(self.items.len());
for (i, item) in self.items.iter().enumerate() {
items.push(build_item(
item,
i == self.selected,
self.label_behavior,
&self.on_select,
i,
ctx,
));
}
NavigationBarWidget {
items,
selected: self.selected,
size: self.size,
safe_area: self.safe_area,
bottom_inset: 0.0,
indicator_style: self.indicator_style,
indicator: LiquidIndicator::new(),
selection_moved: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut NavigationBarWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
let common = prev.items.len().min(self.items.len());
for i in 0..common {
let prev_item = &prev.items[i];
let next_item = &self.items[i];
let was_selected = i == prev.selected;
let now_selected = i == self.selected;
let pod = &mut element.items[i];
let widget = pod
.widget_mut()
.downcast_mut::<NavItemWidget>()
.expect("nav item pod holds a NavItemWidget");
match (&prev_item.icon, &next_item.icon) {
(Some(p), Some(n)) => {
flags |= frust::authoring::rebuild_child(
p,
n,
widget.icon.as_mut().expect("icon pod present"),
ctx,
);
}
(None, Some(n)) => {
widget.icon = Some(frust::authoring::build_child(n, ctx));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(Some(p), None) => {
if let Some(mut old) = widget.icon.take() {
frust::authoring::teardown_child(p, &mut old, ctx);
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(None, None) => {}
}
let prev_role = label_role(was_selected);
let next_role = label_role(now_selected);
let was_shown = label_shown(prev.label_behavior, was_selected);
let now_shown = label_shown(self.label_behavior, now_selected);
widget.label_text = next_item.label.clone();
match (was_shown, now_shown) {
(true, true) => {
if (prev_item.label != next_item.label || prev_role != next_role)
&& let Some(label) = widget.label.as_mut()
{
let prev_view = label_view::<State>(prev_item.label.clone(), prev_role);
let next_view = label_view::<State>(next_item.label.clone(), next_role);
flags |=
frust::authoring::rebuild_child(&prev_view, &next_view, label, ctx);
}
}
(false, true) => {
let view = label_view::<State>(next_item.label.clone(), next_role);
widget.label = Some(frust::authoring::build_child(&view, ctx));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(true, false) => {
if let Some(mut label) = widget.label.take() {
let view = label_view::<State>(prev_item.label.clone(), prev_role);
frust::authoring::teardown_child(&view, &mut label, ctx);
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(false, false) => {}
}
if was_selected != now_selected {
widget.selected = now_selected;
if now_selected {
widget.icon_pop = launched_pop();
} else {
widget.icon_pop = settled_pop();
}
flags |= ChangeFlags::PAINT;
}
widget.on_select = item_on_select::<State>(&self.on_select, i);
}
if self.items.len() > prev.items.len() {
for (offset, item) in self.items[common..].iter().enumerate() {
let idx = common + offset;
element.items.push(build_item(
item,
idx == self.selected,
self.label_behavior,
&self.on_select,
idx,
ctx,
));
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else if self.items.len() < prev.items.len() {
for (offset, item) in prev.items[common..].iter().enumerate() {
let idx = common + offset;
let pod = &mut element.items[idx];
if pod.is_active() {
cancel_item(pod);
pod.set_active(false);
}
let widget = pod
.widget_mut()
.downcast_mut::<NavItemWidget>()
.expect("nav item pod holds a NavItemWidget");
teardown_item(item, idx == prev.selected, prev.label_behavior, widget, ctx);
}
element.items.truncate(common);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.selected != self.selected {
element.selected = self.selected;
element.selection_moved = true;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.size != self.size {
element.size = self.size;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.safe_area != self.safe_area {
element.safe_area = self.safe_area;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.label_behavior != self.label_behavior {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.indicator_style != self.indicator_style {
element.indicator_style = self.indicator_style;
flags |= ChangeFlags::PAINT;
}
flags
}
fn teardown(&self, element: &mut NavigationBarWidget, ctx: &mut BuildCtx<'_>) {
for (i, (item, pod)) in self.items.iter().zip(element.items.iter_mut()).enumerate() {
let widget = pod
.widget_mut()
.downcast_mut::<NavItemWidget>()
.expect("nav item pod holds a NavItemWidget");
teardown_item(item, i == self.selected, self.label_behavior, widget, ctx);
}
}
}
impl NavigationBarWidget {
fn selected_indicator_rect(&mut self) -> Option<Rect> {
let last = self.items.len().checked_sub(1)?;
let index = self.selected.min(last);
let pod = self.items.get_mut(index)?;
let origin = pod.origin();
let local = pod
.widget_mut()
.downcast_mut::<NavItemWidget>()
.expect("nav item pod holds a NavItemWidget")
.indicator_rect;
Some(local + origin.to_vec2())
}
}
impl Widget for NavigationBarWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
let band = self.size.height();
let inset = if self.safe_area {
ctx.window_insets().padding().bottom
} else {
0.0
};
self.bottom_inset = inset;
let height = band + inset;
let reduce_motion = Theme::from_layout_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
let travel = std::mem::take(&mut self.selection_moved)
&& self.indicator_style == NavBarIndicatorStyle::Pill
&& !reduce_motion;
let n = self.items.len();
if n == 0 {
return bc.constrain(Size::new(width, height));
}
let slot_w = width / n as f64;
for (i, pod) in self.items.iter_mut().enumerate() {
pod.layout_child(ctx, &BoxConstraints::tight(Size::new(slot_w, band)));
pod.set_origin(Point::new(i as f64 * slot_w, 0.0));
}
if let Some(rest) = self.selected_indicator_rect() {
self.indicator.sync(rest, travel);
}
bc.constrain(Size::new(width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let (container_fill, indicator_fill) = resolve_colors(theme);
let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
scene.fill_rect(ctx.origin(), ctx.size(), container_fill);
match self.indicator_style {
NavBarIndicatorStyle::Pill => {
if reduce_motion {
self.indicator.settle();
} else if self.indicator.advance(ctx.frame_time()) {
ctx.request_frame();
}
if let Some(pill) = self.indicator.pill_rect() {
scene.fill_rounded_rect(
ctx.origin() + Vec2::new(pill.x0, pill.y0),
pill.size(),
self.indicator.radius(),
indicator_fill,
);
}
}
NavBarIndicatorStyle::Underline => {
if let Some(rest) = self.selected_indicator_rect() {
scene.fill_rect(
ctx.origin() + Vec2::new(rest.x0, rest.y1 - UNDERLINE_THICKNESS),
Size::new(rest.width(), UNDERLINE_THICKNESS),
indicator_fill,
);
}
}
NavBarIndicatorStyle::None => {}
}
for pod in &mut self.items {
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
frust::authoring::route_event(&mut self.items, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::TabList,
|_| {},
|ctx| {
for pod in &self.items {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(items);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::text::TextContext;
use frust::authoring::{BuildCtx, PointerButton, PointerEvent, WindowEdgeInsets, WindowInsets};
use frust_widgets::test_support::{RecordingScene, leaf_any};
use std::any::Any;
use std::cell::Cell;
fn ctx(counter: &mut u64) -> BuildCtx<'_> {
BuildCtx::new(counter)
}
fn build(view: &NavigationBarView<()>) -> NavigationBarWidget {
let mut counter = 0u64;
View::<()>::build(view, &mut ctx(&mut counter))
}
fn layout(w: &mut NavigationBarWidget, bc: &BoxConstraints) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
w.layout(&mut lctx, bc)
}
fn layout_themed(w: &mut NavigationBarWidget, bc: &BoxConstraints, theme: &Theme) -> Size {
let mut tcx = TextContext::new();
let mut lctx =
LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(theme as &dyn Any));
w.layout(&mut lctx, bc)
}
fn layout_with_bottom_inset(
w: &mut NavigationBarWidget,
bc: &BoxConstraints,
bottom: f64,
) -> Size {
let insets = WindowInsets::new(
WindowEdgeInsets::new(0.0, 0.0, 0.0, bottom),
WindowEdgeInsets::ZERO,
);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
lctx.with_window_insets(insets, |ctx| w.layout(ctx, bc))
}
fn three_items() -> Vec<NavItem<()>> {
vec![nav_item("Home"), nav_item("Search"), nav_item("Profile")]
}
fn ft(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
#[derive(Default)]
struct PillRecorder {
rects: Vec<(Point, Size)>,
rounded: Vec<(Point, Size, f64)>,
transforms: Vec<Affine>,
transform_pops: u32,
}
impl PaintScene for PillRecorder {
fn fill_rect(&mut self, origin: Point, size: Size, _color: Color) {
self.rects.push((origin, size));
}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, _color: Color) {
self.rounded.push((origin, size, radius));
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn push_transform(&mut self, transform: Affine) {
self.transforms.push(transform);
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
}
fn paint_at(w: &mut NavigationBarWidget, size: Size, time: FrameTime) -> (PillRecorder, bool) {
let mut scene = PillRecorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, size, time);
w.paint(&mut pctx, &mut scene);
let needs_frame = pctx.needs_frame();
(scene, needs_frame)
}
fn paint_themed_at(
w: &mut NavigationBarWidget,
size: Size,
time: FrameTime,
theme: &Theme,
) -> (PillRecorder, bool) {
let mut scene = PillRecorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, size, time).with_theme(theme);
w.paint(&mut pctx, &mut scene);
let needs_frame = pctx.needs_frame();
(scene, needs_frame)
}
#[test]
fn layout_divides_width_evenly_and_fills_height() {
let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
assert_eq!(size, Size::new(300.0, NavBarSize::default().height()));
assert_eq!(w.items[0].origin().x, 0.0);
assert_eq!(w.items[1].origin().x, 100.0);
assert_eq!(w.items[2].origin().x, 200.0);
assert_eq!(
w.items[0].size(),
Size::new(100.0, NavBarSize::default().height())
);
}
#[test]
fn the_two_sizes_carry_their_reference_container_heights() {
assert_eq!(NavBarSize::Small.height(), HEIGHT_SMALL);
assert_eq!(NavBarSize::Medium.height(), HEIGHT_MEDIUM);
for (size, expected) in [
(NavBarSize::Small, HEIGHT_SMALL),
(NavBarSize::Medium, HEIGHT_MEDIUM),
] {
let view: NavigationBarView<()> =
navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).size(size);
let mut w = build(&view);
let laid = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
assert_eq!(laid.height, expected, "{size:?}");
assert_eq!(w.items[0].size().height, expected, "{size:?}");
}
}
#[test]
fn default_size_is_medium() {
let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
assert_eq!(size, Size::new(300.0, HEIGHT_MEDIUM));
assert_eq!(w.items[0].size().height, HEIGHT_MEDIUM);
}
#[test]
fn safe_area_grows_the_bar_by_the_consumed_bottom_inset() {
let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = build(&view);
let size = layout_with_bottom_inset(
&mut w,
&BoxConstraints::loose(Size::new(300.0, 200.0)),
34.0,
);
assert_eq!(
size,
Size::new(300.0, NavBarSize::default().height() + 34.0)
);
assert_eq!(w.bottom_inset, 34.0);
}
#[test]
fn items_stay_at_the_bare_band_height_under_a_self_consumed_inset() {
let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = build(&view);
layout_with_bottom_inset(
&mut w,
&BoxConstraints::loose(Size::new(300.0, 200.0)),
34.0,
);
for item in &w.items {
assert_eq!(
item.origin().y,
0.0,
"items sit at the top of the taller bar"
);
assert_eq!(
item.size().height,
NavBarSize::default().height(),
"items stay the bare band tall"
);
}
}
#[test]
fn indicator_geometry_is_unchanged_by_a_self_consumed_inset() {
let view: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
let bc = BoxConstraints::loose(Size::new(300.0, 200.0));
let mut zero = build(&view);
layout(&mut zero, &bc);
let (zero_scene, _) = paint_at(
&mut zero,
Size::new(300.0, NavBarSize::default().height()),
ft(0.0),
);
let mut inset = build(&view);
layout_with_bottom_inset(&mut inset, &bc, 34.0);
let (inset_scene, _) = paint_at(
&mut inset,
Size::new(300.0, NavBarSize::default().height() + 34.0),
ft(0.0),
);
assert_eq!(
zero_scene.rounded, inset_scene.rounded,
"the pill's geometry is item-local and byte-identical for a nonzero inset"
);
}
#[test]
fn safe_area_false_ignores_the_window_inset() {
let view: NavigationBarView<()> =
navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).safe_area(false);
let mut w = build(&view);
let size = layout_with_bottom_inset(
&mut w,
&BoxConstraints::loose(Size::new(300.0, 200.0)),
34.0,
);
assert_eq!(size, Size::new(300.0, NavBarSize::default().height()));
assert_eq!(w.bottom_inset, 0.0, "an opted-out bar consumes nothing");
}
#[test]
fn container_fill_covers_the_full_band_plus_inset_size() {
let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = build(&view);
layout_with_bottom_inset(
&mut w,
&BoxConstraints::loose(Size::new(300.0, 200.0)),
34.0,
);
let (scene, _) = paint_at(
&mut w,
Size::new(300.0, NavBarSize::default().height() + 34.0),
ft(0.0),
);
assert_eq!(
scene.rects[0],
(
Point::ZERO,
Size::new(300.0, NavBarSize::default().height() + 34.0)
),
"the container fill covers the band plus the consumed inset"
);
}
#[test]
fn rebuild_flipping_safe_area_returns_layout() {
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
let next: NavigationBarView<()> =
navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).safe_area(false);
let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
assert!(
flags.needs_layout(),
"safe_area toggles the self-inset, a layout change"
);
assert!(!w.safe_area);
}
#[test]
fn label_behavior_decides_which_destinations_hold_a_label_child() {
let cases = [
(NavBarLabelBehavior::AlwaysShow, [true, true, true]),
(NavBarLabelBehavior::OnlySelected, [false, true, false]),
(NavBarLabelBehavior::AlwaysHide, [false, false, false]),
];
for (behavior, expected) in cases {
let view: NavigationBarView<()> =
navigation_bar(three_items(), 1, |_s: &mut (), _i| {}).label_behavior(behavior);
let mut w = build(&view);
layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
for (i, want) in expected.iter().enumerate() {
let item = w.items[i]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
assert_eq!(item.label.is_some(), *want, "{behavior:?} item {i}");
assert!(!item.label_text.is_empty());
}
}
}
#[test]
fn a_hidden_label_centres_the_indicator_box_on_its_own() {
let hidden: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {})
.label_behavior(NavBarLabelBehavior::AlwaysHide);
let mut w = build(&hidden);
layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
let item = w.items[0]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
let expected_top = (NavBarSize::default().height() - INDICATOR_H) / 2.0;
assert!((item.indicator_rect.y0 - expected_top).abs() < 1e-9);
assert_eq!(item.indicator_rect.width(), INDICATOR_W);
}
#[test]
fn changing_the_label_behavior_asks_for_a_relayout() {
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
let next: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {})
.label_behavior(NavBarLabelBehavior::AlwaysHide);
let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
assert!(flags.needs_layout(), "label presence is a layout input");
let item = w.items[0]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
assert!(item.label.is_none(), "the label child was torn down");
}
#[test]
fn the_three_indicator_styles_paint_a_pill_an_underline_or_nothing() {
let bar = Size::new(300.0, HEIGHT_SMALL);
let pill: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
let mut w = build(&pill);
layout(&mut w, &BoxConstraints::loose(bar));
let (scene, _) = paint_at(&mut w, bar, ft(0.0));
assert_eq!(scene.rects.len(), 1, "container fill only");
assert_eq!(scene.rounded.len(), 1, "one stadium pill");
let (origin, size, radius) = scene.rounded[0];
assert_eq!(size, Size::new(INDICATOR_W, INDICATOR_H));
assert_eq!(radius, INDICATOR_H / 2.0);
assert!((origin.x + size.width / 2.0 - 150.0).abs() < 1e-9);
let underline: NavigationBarView<()> =
navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
.indicator_style(NavBarIndicatorStyle::Underline);
let mut w = build(&underline);
layout(&mut w, &BoxConstraints::loose(bar));
let (scene, _) = paint_at(&mut w, bar, ft(0.0));
assert!(scene.rounded.is_empty(), "no pill in underline style");
assert_eq!(scene.rects.len(), 2, "container plus underline");
let (origin, size) = scene.rects[1];
assert_eq!(size, Size::new(INDICATOR_W, UNDERLINE_THICKNESS));
assert!((origin.x + size.width / 2.0 - 150.0).abs() < 1e-9);
let none: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
.indicator_style(NavBarIndicatorStyle::None);
let mut w = build(&none);
layout(&mut w, &BoxConstraints::loose(bar));
let (scene, needs_frame) = paint_at(&mut w, bar, ft(0.0));
assert!(scene.rounded.is_empty());
assert_eq!(scene.rects.len(), 1, "container fill only");
assert!(!needs_frame);
}
#[test]
fn the_edge_springs_match_the_reference_preset_with_only_damping_overridden() {
let reference = crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT;
assert_eq!(LEAD_SPRING.stiffness, reference.stiffness);
assert_eq!(TRAIL_SPRING.stiffness, reference.stiffness);
assert_eq!(ICON_POP_SPRING.stiffness, reference.stiffness);
assert_eq!(LEAD_SPRING.mass, 1.0);
assert_eq!(TRAIL_SPRING.mass, 1.0);
assert_eq!(LEAD_SPRING.damping_ratio, 0.45);
assert_eq!(TRAIL_SPRING.damping_ratio, 0.55);
assert_eq!(ICON_POP_SPRING.damping_ratio, 0.5);
const {
assert!(LEAD_SPRING.damping_ratio < TRAIL_SPRING.damping_ratio);
}
}
#[test]
fn the_pill_stretches_toward_the_new_destination_and_then_settles() {
let bar = Size::new(300.0, HEIGHT_SMALL);
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(bar));
let (scene, needs_frame) = paint_at(&mut w, bar, ft(0.0));
assert_eq!(scene.rounded[0].1.width, INDICATOR_W, "resting at item 0");
assert!(!needs_frame, "a settled pill asks for no frames");
let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(bar));
assert!(w.indicator.is_animating(), "the travel started at layout");
let mut widest: f64 = 0.0;
let mut widest_at = 0usize;
let mut lead_ahead = false;
let mut settled_at = None;
for frame in 0..240 {
let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
let (origin, size, _) = scene.rounded[0];
if size.width > widest {
widest = size.width;
widest_at = frame;
}
if origin.x + size.width > 250.0 + INDICATOR_W / 2.0 - 1.0 && size.width > INDICATOR_W {
lead_ahead = true;
}
if !needs_frame {
settled_at = Some(frame);
break;
}
}
assert!(
widest > INDICATOR_W + 1.0,
"phase 1 never stretched: widest {widest} vs resting {INDICATOR_W}"
);
assert!(widest_at > 0, "the stretch builds up over frames");
assert!(lead_ahead, "the leading edge reaches the new slot first");
let settled_at = settled_at.expect("the pill settles within 240 frames");
assert!(settled_at > widest_at, "settling follows the stretch");
let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * (settled_at + 1) as f64));
assert!(!needs_frame, "a settled pill stops asking for frames");
let (origin, size, _) = scene.rounded[0];
assert!(
(size.width - INDICATOR_W).abs() < 1e-6,
"phase 2 must collapse the bridge back to the resting width"
);
assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-6);
}
#[test]
fn a_same_geometry_relayout_mid_travel_does_not_snap_the_pill() {
let bar = Size::new(300.0, HEIGHT_SMALL);
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(bar));
paint_at(&mut w, bar, ft(0.0));
let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(bar));
assert!(w.indicator.is_animating(), "the travel started at layout");
paint_at(&mut w, bar, ft(16.0));
paint_at(&mut w, bar, ft(32.0));
let (scene, _) = paint_at(&mut w, bar, ft(48.0));
let mid_flight_width = scene.rounded[0].1.width;
assert!(
mid_flight_width > INDICATOR_W + 1.0,
"must actually be stretched before the interleaved layout, got {mid_flight_width}"
);
layout(&mut w, &BoxConstraints::loose(bar));
assert!(
w.indicator.is_animating(),
"a same-geometry relayout mid-travel must not cancel the travel"
);
let (scene, _) = paint_at(&mut w, bar, ft(64.0));
let width_after = scene.rounded[0].1.width;
assert!(
width_after > INDICATOR_W + 1.0,
"the pill must still be mid-flight after the interleaved layout, \
not snapped to rest (got {width_after})"
);
let mut settled = false;
for frame in 5..240 {
let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
if !needs_frame {
let (origin, size, _) = scene.rounded[0];
assert!((size.width - INDICATOR_W).abs() < 1e-6);
assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-6);
settled = true;
break;
}
}
assert!(
settled,
"the pill still settles within 240 frames despite the interleave"
);
}
#[test]
fn a_width_change_jumps_the_pill_instead_of_travelling() {
let mut counter = 0u64;
let view: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&view, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
layout(&mut w, &BoxConstraints::loose(Size::new(600.0, 200.0)));
assert!(!w.indicator.is_animating(), "a relayout never travels");
let pill = w.indicator.pill_rect().expect("laid out");
assert!((pill.width() - INDICATOR_W).abs() < 1e-9);
assert!((pill.center().x - 300.0).abs() < 1e-9);
}
#[test]
fn reduce_motion_snaps_the_liquid_travel_and_asks_for_no_frames() {
let bar = Size::new(300.0, HEIGHT_SMALL);
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
layout_themed(&mut w, &BoxConstraints::loose(bar), &theme);
let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
layout_themed(&mut w, &BoxConstraints::loose(bar), &theme);
assert!(!w.indicator.is_animating(), "reduce_motion never travels");
let (scene, needs_frame) = paint_themed_at(&mut w, bar, ft(0.0), &theme);
assert!(!needs_frame, "reduce_motion asks for no continuation frame");
let (origin, size, _) = scene.rounded[0];
assert!((size.width - INDICATOR_W).abs() < 1e-9, "no stretch at all");
assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-9);
}
#[test]
fn selecting_an_item_pops_its_icon_and_then_returns_to_rest() {
let bar = Size::new(300.0, HEIGHT_SMALL);
let mut counter = 0u64;
let items = || {
vec![
nav_item::<()>("Home").icon(leaf_any(24.0, 24.0)),
nav_item::<()>("Search").icon(leaf_any(24.0, 24.0)),
]
};
let prev: NavigationBarView<()> = navigation_bar(items(), 0, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(bar));
let (scene, _) = paint_at(&mut w, bar, ft(0.0));
assert!(
scene.transforms.is_empty(),
"a settled bar pushes no scale transform"
);
let next: NavigationBarView<()> = navigation_bar(items(), 1, |_s: &mut (), _i| {});
View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
let item = w.items[1]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
assert!(item.icon_pop.is_animating(), "the incoming icon pops");
assert!(
(item.icon_pop.value() - 0.0).abs() < 1e-9,
"starts at 0.98x"
);
layout(&mut w, &BoxConstraints::loose(bar));
let mut settled = false;
let mut scaled_frames = 0;
let mut overshot = false;
for frame in 0..240 {
let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
assert_eq!(
scene.transforms.len() as u32,
scene.transform_pops,
"every pushed transform is popped"
);
if let Some(t) = scene.transforms.first() {
scaled_frames += 1;
if t.determinant() > 1.0 {
overshot = true;
}
}
if !needs_frame {
settled = true;
break;
}
}
assert!(settled, "the pop settles");
assert!(scaled_frames > 0, "the pop actually scaled the icon");
assert!(overshot, "the pop overshoots past the resting scale");
let item = w.items[1]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
assert!((item.icon_pop.value() - 1.0).abs() < 1e-9, "rests at 1.0x");
}
#[test]
fn a_badge_wraps_the_icon_without_moving_the_hit_target() {
let bar = Size::new(300.0, NavBarSize::default().height());
let bare: NavigationBarView<()> = navigation_bar(
vec![nav_item("Home").icon(leaf_any(24.0, 24.0))],
0,
|_s: &mut (), _i| {},
);
let mut w = build(&bare);
layout(&mut w, &BoxConstraints::loose(bar));
let bare_icon = w.items[0]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap()
.icon
.as_ref()
.expect("icon pod present")
.size();
assert_eq!(bare_icon, Size::new(24.0, 24.0));
let tapped: Rc<Cell<Option<usize>>> = Rc::new(Cell::new(None));
let sink = tapped.clone();
let badged: NavigationBarView<()> = navigation_bar(
vec![
nav_item("Home").icon(leaf_any(24.0, 24.0)).badge_count(3),
nav_item("Search").icon(leaf_any(24.0, 24.0)).badge_dot(),
],
0,
move |_s: &mut (), i| sink.set(Some(i)),
);
let mut w = build(&badged);
layout(&mut w, &BoxConstraints::loose(bar));
let item = w.items[0]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
let badged_icon = item.icon.as_ref().expect("icon pod present").size();
assert!(
badged_icon.width > bare_icon.width,
"the badge composes onto the icon: {badged_icon:?}"
);
assert!(badged_icon.height <= INDICATOR_H, "still inside the box");
assert_eq!(
w.items[1].size(),
Size::new(150.0, NavBarSize::default().height())
);
let mut unit = ();
let unit_any: &mut dyn Any = &mut unit;
let mut ectx = EventCtx::new(
unit_any,
Point::ZERO,
Size::new(300.0, NavBarSize::default().height()),
);
w.event(&mut ectx, &ev(PointerPhase::Down, 200.0, 20.0));
w.event(&mut ectx, &ev(PointerPhase::Up, 200.0, 20.0));
assert_eq!(
tapped.get(),
Some(1),
"a badged bar still reports the tapped index"
);
}
#[test]
fn unthemed_paint_uses_fallback_container() {
let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = build(&view);
layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
let mut scene = RecordingScene::default();
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT_SMALL));
w.paint(&mut pctx, &mut scene);
assert_eq!(scene.rects[0].1, Size::new(300.0, HEIGHT_SMALL));
}
#[test]
fn themed_paint_resolves_surface_container_and_the_indicator_role() {
let theme = crate::baseline();
let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = build(&view);
layout_themed(
&mut w,
&BoxConstraints::loose(Size::new(300.0, 200.0)),
&theme,
);
#[derive(Default)]
struct ColorRecorder {
colors: Vec<Color>,
rounded: Vec<Color>,
}
impl PaintScene for ColorRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, c: Color) {
self.colors.push(c);
}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, c: Color) {
self.rounded.push(c);
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
let mut scene = ColorRecorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, Size::new(300.0, HEIGHT_SMALL), ft(0.0))
.with_theme(&theme);
w.paint(&mut pctx, &mut scene);
assert_eq!(scene.colors[0], theme.scheme().surface_container);
assert_eq!(scene.rounded[0], theme.scheme().secondary_container);
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
#[allow(clippy::ptr_arg)]
fn dispatch(w: &mut NavigationBarWidget, state: &mut Vec<usize>, event: &InputEvent) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT_SMALL));
w.event(&mut ctx, event);
}
#[test]
fn tap_inside_an_item_reports_its_index_without_self_mutating() {
let view: NavigationBarView<Vec<usize>> = navigation_bar(
vec![nav_item("Home"), nav_item("Search"), nav_item("Profile")],
0,
|s: &mut Vec<usize>, i| s.push(i),
);
let mut counter = 0u64;
let mut w = View::<Vec<usize>>::build(&view, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
let mut log: Vec<usize> = Vec::new();
dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 150.0, 20.0));
dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 150.0, 20.0));
assert_eq!(log, vec![1]);
assert_eq!(w.selected, 0);
let item = w.items[1]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
assert!(!item.selected, "a tap alone never selects");
}
#[test]
fn rebuild_adopts_new_selected_index() {
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
let widget0 = w.items[0]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
assert!(widget0.selected);
let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
assert!(flags.needs_paint());
assert!(
flags.needs_layout(),
"the pill's travel is resolved in layout"
);
let widget0 = w.items[0]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
assert!(!widget0.selected, "selection moved off item 0");
let widget2 = w.items[2]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
assert!(widget2.selected, "item 2 is now selected");
assert_eq!(w.selected, 2);
assert!(w.selection_moved, "the next layout starts the travel");
}
#[test]
fn growing_item_list_builds_a_fresh_unselected_item() {
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(
vec![nav_item("Home"), nav_item("Search")],
0,
|_s: &mut (), _i| {},
);
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
assert_eq!(w.items.len(), 2);
let next: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
assert_eq!(w.items.len(), 3);
assert!(flags.needs_layout());
}
#[test]
fn shrinking_item_list_tears_down_the_removed_item() {
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
let next: NavigationBarView<()> =
navigation_bar(vec![nav_item("Home")], 0, |_s: &mut (), _i| {});
let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
assert_eq!(w.items.len(), 1);
assert!(flags.needs_layout());
}
#[test]
fn a_selection_past_the_last_item_still_places_the_indicator() {
let mut counter = 0u64;
let prev: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
let next: NavigationBarView<()> =
navigation_bar(vec![nav_item("Home")], 2, |_s: &mut (), _i| {});
View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
let pill = w.indicator.pill_rect().expect("clamped onto the last item");
assert!((pill.center().x - 150.0).abs() < 1e-9);
}
#[test]
fn icon_slot_is_routed_and_laid_out_above_the_label() {
let view: NavigationBarView<()> = navigation_bar(
vec![nav_item("Home").icon(leaf_any(24.0, 24.0))],
0,
|_s: &mut (), _i| {},
);
let mut w = build(&view);
layout(&mut w, &BoxConstraints::loose(Size::new(100.0, 200.0)));
let widget0 = w.items[0]
.widget_mut()
.downcast_mut::<NavItemWidget>()
.unwrap();
let icon = widget0.icon.as_ref().expect("icon pod present");
let label = widget0.label.as_ref().expect("label pod present");
assert!(icon.origin().y < label.origin().y);
}
#[test]
fn semantics_yields_a_tablist_of_tabs_with_selection() {
fn logic(_state: &mut ()) -> NavigationBarView<()> {
navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
}
let mut root: frust_core::RenderRoot<(), NavigationBarView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(300.0, HEIGHT_SMALL), &mut tcx as &mut dyn Any);
let update = root.semantics();
let tablist = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TabList)
.expect("a TabList container node is contributed");
assert_eq!(tablist.1.children().len(), 3);
let tabs: Vec<_> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::Tab)
.collect();
assert_eq!(tabs.len(), 3);
let selected_tab = tabs
.iter()
.find(|(_, n)| n.label() == Some("Search"))
.expect("the Search tab is present");
assert_eq!(selected_tab.1.is_selected(), Some(true));
let unselected_tab = tabs
.iter()
.find(|(_, n)| n.label() == Some("Home"))
.expect("the Home tab is present");
assert_eq!(unselected_tab.1.is_selected(), Some(false));
}
#[test]
fn a_hidden_label_still_reaches_the_semantics_node() {
fn logic(_state: &mut ()) -> NavigationBarView<()> {
navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
.label_behavior(NavBarLabelBehavior::AlwaysHide)
}
let mut root: frust_core::RenderRoot<(), NavigationBarView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(300.0, HEIGHT_SMALL), &mut tcx as &mut dyn Any);
let update = root.semantics();
let labelled = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::Tab && n.label().is_some())
.count();
assert_eq!(
labelled, 3,
"every tab keeps its label with no text painted"
);
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn every_label_paints_in_roboto_flex_under_the_material_theme() {
use crate::appbar::top::typeface_probe::{Face, assert_paints_only_in};
let flex = crate::tokens::font_data()[0];
let runs = assert_paints_only_in(
"the navigation bar's labels",
|_: &mut ()| navigation_bar(three_items(), 1, |_s: &mut (), _i| {}),
crate::baseline(),
&[flex],
Size::new(300.0, HEIGHT_SMALL),
Face {
bytes: flex,
name: "Roboto Flex",
},
);
assert!(
runs >= 3,
"fixture sanity: expected a run per label, got {runs}"
);
}
}