use std::rc::Rc;
use std::time::Duration;
use frust::authoring::text::{
FontWeight, LineHeight, TextContext, TextLayout, TextOverflow, TextStyle,
};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon, ErasedCallback, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
View, Widget, any, build_child, erase_callback, rebuild_child, route_event, route_event_single,
teardown_child, visit_children,
};
use frust::{
AnimationController, Curve, FrameTime, IconSource, Spring, SpringDesc, Theme, Tween, icon,
};
use kurbo::{Point, Rect, Size};
use peniko::{Brush, Color};
use crate::fab::{FabColor, FabSize, extended_fab, fab};
use crate::icon_button::icon_button;
use crate::overlay::{OVERLAY_SCRIM_ALPHA, finite_or_zero};
use crate::press::presses;
pub const RAIL_COLLAPSED_WIDTH: f64 = 96.0;
pub const RAIL_EXPANDED_MIN_WIDTH: f64 = 220.0;
pub const RAIL_EXPANDED_MAX_WIDTH: f64 = 360.0;
pub const RAIL_ITEM_COLLAPSED_HEIGHT: f64 = 66.0;
pub const RAIL_ITEM_EXPANDED_HEIGHT: f64 = 40.0;
const ICON_SIZE: f64 = 24.0;
const INDICATOR_LEADING: f64 = 16.0;
const INDICATOR_TRAILING: f64 = 16.0;
const ICON_LABEL_GAP: f64 = 8.0;
const ITEM_VERTICAL_GAP: f64 = 4.0;
const SECTION_HEADER_SPACING_TOP: f64 = 12.0;
const SECTION_HEADER_SPACING_BOTTOM: f64 = 8.0;
const HORIZONTAL_INSET: f64 = 16.0;
const SECTION_PADDING_BOTTOM: f64 = 12.0;
const TOP_GAP: f64 = 36.0;
const CHIP_WIDTH: f64 = 52.0;
const CHIP_HEIGHT: f64 = 40.0;
const CHIP_TARGET_HEIGHT: f64 = 48.0;
const BADGE_DOT: f64 = 8.0;
const BADGE_H_PAD: f64 = 6.0;
const BADGE_V_PAD: f64 = 4.0;
const BADGE_MAX_COUNT: u32 = 999;
pub const RAIL_EXPAND_DURATION: Duration = Duration::from_millis(280);
pub const RAIL_EXPAND_CURVE: Curve = Curve::Cubic(0.215, 0.61, 0.355, 1.0);
pub const RAIL_LEAD_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 380.0,
damping_ratio: 0.45,
};
pub const RAIL_TRAIL_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 380.0,
damping_ratio: 0.55,
};
const TRAVEL_REST_EPSILON: f64 = 0.01;
const TRAVEL_EPSILON: f64 = 0.5;
type TypeToken = (f32, f32, f32, FontWeight);
const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
const LABEL_SMALL: TypeToken = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
const TITLE_SMALL: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
const SHAPING_INK: Color = Color::BLACK;
const SURFACE: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
const SCRIM: Color = Color::from_rgb8(0x00, 0x00, 0x00);
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
fn type_style(themed: Option<&TextStyle>, token: TypeToken) -> TextStyle {
let mut style = match themed {
Some(style) => style.clone(),
None => {
let (size, line_height, letter_spacing, weight) = token;
let mut style = TextStyle::new(size, SHAPING_INK);
style.line_height = LineHeight::Absolute(line_height);
style.letter_spacing = letter_spacing;
style.weight = weight;
style
}
};
style.color = SHAPING_INK;
style
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NavigationRailType {
Collapsed,
AlwaysCollapse,
#[default]
Expanded,
AlwaysExpand,
}
impl NavigationRailType {
pub fn is_expanded(self) -> bool {
matches!(self, Self::Expanded | Self::AlwaysExpand)
}
pub fn can_toggle(self) -> bool {
matches!(self, Self::Collapsed | Self::Expanded)
}
fn toggled(self) -> Self {
if self.is_expanded() {
Self::Collapsed
} else {
Self::Expanded
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NavigationRailModality {
#[default]
Standard,
Modal,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RailLabelBehavior {
#[default]
AlwaysShow,
OnlySelected,
AlwaysHide,
}
impl RailLabelBehavior {
fn shows(self, selected: bool) -> bool {
match self {
Self::AlwaysShow => true,
Self::OnlySelected => selected,
Self::AlwaysHide => false,
}
}
}
pub struct RailDestination<State: 'static> {
icon: AnyView<State>,
selected_icon: Option<AnyView<State>>,
label: String,
badge_count: Option<u32>,
semantic_label: Option<String>,
}
pub fn rail_destination<State: 'static>(
icon: impl View<State>,
label: impl Into<String>,
) -> RailDestination<State> {
RailDestination {
icon: AnyView::new(icon),
selected_icon: None,
label: label.into(),
badge_count: None,
semantic_label: None,
}
}
impl<State: 'static> RailDestination<State> {
pub fn selected_icon(mut self, icon: impl View<State>) -> Self {
self.selected_icon = Some(AnyView::new(icon));
self
}
pub fn badge_count(mut self, count: u32) -> Self {
self.badge_count = Some(count);
self
}
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
fn active_icon(&self, selected: bool) -> &AnyView<State> {
if selected {
self.selected_icon.as_ref().unwrap_or(&self.icon)
} else {
&self.icon
}
}
}
pub struct RailSection<State: 'static> {
header: Option<String>,
destinations: Vec<RailDestination<State>>,
}
pub fn rail_section<State: 'static>(
destinations: Vec<RailDestination<State>>,
) -> RailSection<State> {
RailSection {
header: None,
destinations,
}
}
impl<State: 'static> RailSection<State> {
pub fn header(mut self, header: impl Into<String>) -> Self {
self.header = Some(header.into());
self
}
}
pub struct RailFab<State: 'static> {
icon: IconSource,
label: String,
on_press: Rc<dyn Fn(&mut State)>,
color: FabColor,
size: FabSize,
semantic_label: Option<String>,
}
pub fn rail_fab<State: 'static, F: Fn(&mut State) + 'static>(
icon: IconSource,
label: impl Into<String>,
on_press: F,
) -> RailFab<State> {
RailFab {
icon,
label: label.into(),
on_press: Rc::new(on_press),
color: FabColor::Primary,
size: FabSize::Medium,
semantic_label: None,
}
}
impl<State: 'static> RailFab<State> {
pub fn color(mut self, color: FabColor) -> Self {
self.color = color;
self
}
pub fn size(mut self, size: FabSize) -> Self {
self.size = size;
self
}
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
fn shape(&self) -> (&str, FabColor, FabSize, Option<&str>) {
(
self.label.as_str(),
self.color,
self.size,
self.semantic_label.as_deref(),
)
}
}
struct TextRun {
content: String,
layout: Option<TextLayout>,
shaped_for: Option<(TextStyle, Option<f64>)>,
}
impl TextRun {
fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
layout: None,
shaped_for: None,
}
}
fn set_content(&mut self, content: &str) -> bool {
if self.content == content {
return false;
}
self.content = content.to_string();
self.layout = None;
self.shaped_for = None;
true
}
fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f64>) -> Size {
let key = (style.clone(), max_width);
if let Some(layout) = &self.layout
&& self.shaped_for.as_ref() == Some(&key)
{
return layout.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = match max_width {
Some(width) => text_ctx.layout_bounded(
&self.content,
style,
Some(width as f32),
Some(1),
TextOverflow::Ellipsis,
),
None => text_ctx.layout(&self.content, style, None),
};
let size = laid.size();
self.layout = Some(laid);
self.shaped_for = Some(key);
size
}
fn size(&self) -> Size {
self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
struct WidthMotion {
value: f64,
target: f64,
tween: Tween<f64>,
driver: AnimationController,
}
impl WidthMotion {
fn resting(width: f64) -> Self {
Self {
value: width,
target: width,
tween: Tween::new(width, width),
driver: AnimationController::new(RAIL_EXPAND_DURATION).with_curve(RAIL_EXPAND_CURVE),
}
}
fn set_target(&mut self, target: f64) {
if self.target == target {
return;
}
self.target = target;
self.tween = Tween::new(self.value, target);
self.driver = AnimationController::new(RAIL_EXPAND_DURATION).with_curve(RAIL_EXPAND_CURVE);
self.driver.forward();
}
fn snap(&mut self) -> bool {
let moved = self.value != self.target;
self.value = self.target;
self.tween = Tween::new(self.target, self.target);
self.driver.stop();
moved
}
fn advance(&mut self, now: FrameTime) -> bool {
let animating = self.driver.advance(now);
self.value = self.tween.lerp(self.driver.value_clamped());
animating
}
fn is_animating(&self) -> bool {
self.driver.is_animating()
}
}
struct TravelAxis {
value: f64,
target: f64,
flight: Option<Spring>,
elapsed: f64,
velocity: f64,
last_time: Option<FrameTime>,
}
impl TravelAxis {
fn new() -> Self {
Self {
value: 0.0,
target: 0.0,
flight: None,
elapsed: 0.0,
velocity: 0.0,
last_time: None,
}
}
fn jump(&mut self, to: f64) {
self.value = to;
self.target = to;
self.flight = None;
self.elapsed = 0.0;
self.velocity = 0.0;
self.last_time = None;
}
fn travel_to(&mut self, to: f64, spring: SpringDesc) {
if (self.target - to).abs() < TRAVEL_EPSILON {
return;
}
self.flight = Some(Spring::new(spring, self.value - to, self.velocity));
self.target = to;
self.elapsed = 0.0;
self.last_time = None;
}
fn advance(&mut self, now: FrameTime) -> bool {
let Some(spring) = self.flight else {
return false;
};
let dt = match self.last_time {
Some(last) => now.saturating_sub(last).as_secs_f64(),
None => 0.0,
};
self.last_time = Some(now);
self.elapsed += dt;
if spring.is_at_rest(self.elapsed, TRAVEL_REST_EPSILON) {
self.value = self.target;
self.velocity = 0.0;
self.flight = None;
self.elapsed = 0.0;
self.last_time = None;
return false;
}
self.value = self.target + spring.position(self.elapsed);
self.velocity = spring.velocity(self.elapsed);
true
}
fn is_animating(&self) -> bool {
self.flight.is_some()
}
}
type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
type OnTypeChanged<State> = Rc<dyn Fn(&mut State, NavigationRailType)>;
type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
pub struct NavigationRailView<State: 'static> {
sections: Vec<RailSection<State>>,
selected: usize,
on_select: OnSelect<State>,
rail_type: NavigationRailType,
modality: NavigationRailModality,
fab: Option<RailFab<State>>,
hide_when_collapsed: bool,
expanded_width: Option<f64>,
on_dismiss_modal: Option<OnDismiss<State>>,
on_type_changed: Option<OnTypeChanged<State>>,
label_behavior: RailLabelBehavior,
trailing: Option<AnyView<State>>,
trailing_at_bottom: bool,
background: Option<Color>,
}
pub fn navigation_rail<State: 'static, F: Fn(&mut State, usize) + 'static>(
sections: Vec<RailSection<State>>,
selected: usize,
on_select: F,
) -> NavigationRailView<State> {
NavigationRailView {
sections,
selected,
on_select: Rc::new(on_select),
rail_type: NavigationRailType::default(),
modality: NavigationRailModality::default(),
fab: None,
hide_when_collapsed: false,
expanded_width: None,
on_dismiss_modal: None,
on_type_changed: None,
label_behavior: RailLabelBehavior::default(),
trailing: None,
trailing_at_bottom: true,
background: None,
}
}
#[allow(non_snake_case)]
pub fn NavigationRail<State: 'static, F: Fn(&mut State, usize) + 'static>(
sections: Vec<RailSection<State>>,
selected: usize,
on_select: F,
) -> NavigationRailView<State> {
navigation_rail(sections, selected, on_select)
}
impl<State: 'static> NavigationRailView<State> {
pub fn rail_type(mut self, rail_type: NavigationRailType) -> Self {
self.rail_type = rail_type;
self
}
pub fn modality(mut self, modality: NavigationRailModality) -> Self {
self.modality = modality;
self
}
pub fn fab(mut self, fab: RailFab<State>) -> Self {
self.fab = Some(fab);
self
}
pub fn hide_when_collapsed(mut self, hide: bool) -> Self {
self.hide_when_collapsed = hide;
self
}
pub fn expanded_width(mut self, width: f64) -> Self {
self.expanded_width = Some(width);
self
}
pub fn on_dismiss_modal<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
self.on_dismiss_modal = Some(Rc::new(on_dismiss));
self
}
pub fn on_type_changed<F: Fn(&mut State, NavigationRailType) + 'static>(
mut self,
on_changed: F,
) -> Self {
self.on_type_changed = Some(Rc::new(on_changed));
self
}
pub fn label_behavior(mut self, behavior: RailLabelBehavior) -> Self {
self.label_behavior = behavior;
self
}
pub fn trailing(mut self, trailing: impl View<State>) -> Self {
self.trailing = Some(AnyView::new(trailing));
self
}
pub fn trailing_at_bottom(mut self, at_bottom: bool) -> Self {
self.trailing_at_bottom = at_bottom;
self
}
pub fn background(mut self, color: Color) -> Self {
self.background = Some(color);
self
}
fn destinations(&self) -> Vec<&RailDestination<State>> {
self.sections
.iter()
.flat_map(|section| section.destinations.iter())
.collect()
}
fn target_width(&self) -> f64 {
if self.rail_type.is_expanded() {
self.expanded_width
.unwrap_or(RAIL_EXPANDED_MIN_WIDTH)
.clamp(RAIL_EXPANDED_MIN_WIDTH, RAIL_EXPANDED_MAX_WIDTH)
} else if self.hide_when_collapsed {
0.0
} else {
RAIL_COLLAPSED_WIDTH
}
}
fn menu_view(&self) -> Option<AnyView<State>> {
if !self.rail_type.can_toggle() {
return None;
}
let expanded = self.rail_type.is_expanded();
let next = self.rail_type.toggled();
let callback = self.on_type_changed.clone();
let (source, label) = if expanded {
(crate::icons::MENU_OPEN, "Collapse")
} else {
(crate::icons::MENU, "Expand")
};
Some(any(icon_button(icon(source), move |state: &mut State| {
if let Some(callback) = &callback {
callback(state, next);
}
})
.semantic_label(label)))
}
fn fab_view(&self) -> Option<AnyView<State>> {
let slot = self.fab.as_ref()?;
let on_press = slot.on_press.clone();
if self.rail_type.is_expanded() {
Some(any(extended_fab(
slot.label.clone(),
move |state: &mut State| {
on_press(state);
},
)
.icon(icon(slot.icon))
.color(slot.color)))
} else {
let label = slot
.semantic_label
.clone()
.unwrap_or_else(|| slot.label.clone());
Some(any(fab(icon(slot.icon), move |state: &mut State| {
on_press(state);
})
.color(slot.color)
.size(slot.size)
.label(label)))
}
}
}
fn item_on_select<State: 'static>(on_select: &OnSelect<State>, index: usize) -> ErasedCallback {
let callback = on_select.clone();
Box::new(move |ctx: &mut EventCtx| {
let state = ctx.state_mut::<State>();
callback(state, index);
})
}
struct RailItemWidget {
icon: ChildPod,
label: TextRun,
semantic_label: Option<String>,
badge_count: Option<u32>,
badge: TextRun,
selected: bool,
expanded: bool,
label_behavior: RailLabelBehavior,
use_local_indicator: bool,
indicator: Rect,
label_origin: Point,
badge_origin: Point,
badge_size: Size,
on_select: ErasedCallback,
captured: bool,
}
impl RailItemWidget {
fn shows_label(&self) -> bool {
self.expanded || self.label_behavior.shows(self.selected)
}
fn badge_text(&self) -> Option<String> {
match self.badge_count {
None | Some(0) => None,
Some(count) if count > BADGE_MAX_COUNT => Some(format!("{BADGE_MAX_COUNT}+")),
Some(count) => Some(count.to_string()),
}
}
}
fn build_item<State: 'static>(
destination: &RailDestination<State>,
selected: bool,
expanded: bool,
label_behavior: RailLabelBehavior,
on_select: &OnSelect<State>,
index: usize,
ctx: &mut BuildCtx<'_>,
) -> ChildPod {
let widget = RailItemWidget {
icon: build_child(destination.active_icon(selected), ctx),
label: TextRun::new(destination.label.clone()),
semantic_label: destination.semantic_label.clone(),
badge_count: destination.badge_count,
badge: TextRun::new(String::new()),
selected,
expanded,
label_behavior,
use_local_indicator: true,
indicator: Rect::ZERO,
label_origin: Point::ZERO,
badge_origin: Point::ZERO,
badge_size: Size::ZERO,
on_select: item_on_select::<State>(on_select, index),
captured: false,
};
ChildPod::new(Box::new(widget))
}
impl Widget for RailItemWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = finite_or_zero(bc.max().width);
let theme = Theme::from_layout_ctx(ctx);
let label_style = type_style(
theme.map(|t| {
if self.expanded {
&t.type_scale.label_large
} else {
&t.type_scale.label_medium
}
}),
if self.expanded {
LABEL_LARGE
} else {
LABEL_MEDIUM
},
);
let badge_style = type_style(theme.map(|t| &t.type_scale.label_small), LABEL_SMALL);
let badge_text = self.badge_text();
self.badge_size = match (&badge_text, self.badge_count) {
(Some(text), _) => {
self.badge.set_content(text);
let measured = self.badge.shape(ctx, &badge_style, None);
Size::new(
measured.width + BADGE_H_PAD * 2.0,
measured.height + BADGE_V_PAD * 2.0,
)
}
(None, Some(_)) => Size::new(BADGE_DOT, BADGE_DOT),
(None, None) => Size::ZERO,
};
if self.expanded {
let badge_slot = if self.badge_size.width > 0.0 {
self.badge_size.width + ICON_LABEL_GAP
} else {
0.0
};
let label_max = (width
- INDICATOR_LEADING
- INDICATOR_TRAILING
- ICON_SIZE
- ICON_LABEL_GAP
- badge_slot)
.max(0.0);
let label_size = self.label.shape(ctx, &label_style, Some(label_max));
let content_height = label_size.height.max(ICON_SIZE).max(self.badge_size.height);
let height = content_height.max(RAIL_ITEM_EXPANDED_HEIGHT);
let icon_measured = self
.icon
.layout_child(ctx, &BoxConstraints::tight(Size::new(ICON_SIZE, ICON_SIZE)));
self.icon.set_origin(Point::new(
INDICATOR_LEADING,
(height - icon_measured.height) / 2.0,
));
self.label_origin = Point::new(
INDICATOR_LEADING + ICON_SIZE + ICON_LABEL_GAP,
(height - label_size.height) / 2.0,
);
self.badge_origin = Point::new(
(width - INDICATOR_TRAILING - self.badge_size.width).max(0.0),
(height - self.badge_size.height) / 2.0,
);
self.indicator = Rect::new(0.0, 0.0, width, height);
return bc.constrain(Size::new(width, height));
}
let label_size = if self.shows_label() {
self.label.shape(ctx, &label_style, Some(width))
} else {
Size::ZERO
};
let content_height = CHIP_TARGET_HEIGHT + label_size.height;
let top = ((RAIL_ITEM_COLLAPSED_HEIGHT - content_height) / 2.0).max(0.0);
let chip = Rect::new(
0.0,
top + (CHIP_TARGET_HEIGHT - CHIP_HEIGHT) / 2.0,
CHIP_WIDTH.min(width),
top + (CHIP_TARGET_HEIGHT + CHIP_HEIGHT) / 2.0,
);
let icon_measured = self
.icon
.layout_child(ctx, &BoxConstraints::tight(Size::new(ICON_SIZE, ICON_SIZE)));
self.icon.set_origin(Point::new(
chip.x0 + (chip.width() - icon_measured.width) / 2.0,
chip.y0 + (chip.height() - icon_measured.height) / 2.0,
));
self.label_origin = Point::new(
chip.x0 + (chip.width() - label_size.width) / 2.0,
top + CHIP_TARGET_HEIGHT,
);
self.badge_origin = Point::new(chip.x1 - self.badge_size.width, chip.y0);
self.indicator = chip;
bc.constrain(Size::new(width, RAIL_ITEM_COLLAPSED_HEIGHT))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let indicator_fill = theme.map_or(SECONDARY_CONTAINER, |t| t.scheme().secondary_container);
let content = if self.selected {
theme.map_or(ON_SECONDARY_CONTAINER, |t| {
t.scheme().on_secondary_container
})
} else {
theme.map_or(ON_SURFACE_VARIANT, |t| t.scheme().on_surface_variant)
};
let badge_bg = theme.map_or(PRIMARY, |t| t.scheme().primary);
let badge_fg = theme.map_or(ON_PRIMARY, |t| t.scheme().on_primary);
let origin = ctx.origin();
if self.selected && self.use_local_indicator {
let rect = self.indicator;
scene.fill_rounded_rect(
Point::new(origin.x + rect.x0, origin.y + rect.y0),
rect.size(),
rect.width().min(rect.height()) / 2.0,
indicator_fill,
);
}
self.icon.paint_child(ctx, scene);
if self.shows_label() {
self.label.paint(
Point::new(
origin.x + self.label_origin.x,
origin.y + self.label_origin.y,
),
content,
scene,
);
}
if self.badge_size.width > 0.0 {
let pill = Point::new(
origin.x + self.badge_origin.x,
origin.y + self.badge_origin.y,
);
scene.fill_rounded_rect(
pill,
self.badge_size,
self.badge_size.height / 2.0,
badge_bg,
);
if self.badge_text().is_some() {
let text = self.badge.size();
self.badge.paint(
Point::new(
pill.x + (self.badge_size.width - text.width) / 2.0,
pill.y + (self.badge_size.height - text.height) / 2.0,
),
badge_fg,
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 inside(p.position, ctx.size()) {
ctx.set_cursor(CursorIcon::Pointer);
}
if self.captured {
EventResult::Handled
} else {
EventResult::Ignored
}
}
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| {
let label = self
.semantic_label
.as_deref()
.unwrap_or(&self.label.content);
node.set_label(label);
node.set_selected(self.selected);
});
}
visit_children!(icon);
}
struct SectionLayout {
first: usize,
count: usize,
header: Option<TextRun>,
header_origin: Point,
}
pub struct NavigationRailWidget {
items: Vec<ChildPod>,
menu: Option<ChildPod>,
fab: Option<ChildPod>,
trailing: Option<ChildPod>,
sections: Vec<SectionLayout>,
selected: usize,
expanded: bool,
modal: bool,
trailing_at_bottom: bool,
background: Option<Color>,
on_dismiss_modal: Option<ErasedCallback>,
width_motion: WidthMotion,
lead: TravelAxis,
trail: TravelAxis,
ready: bool,
pending_travel: bool,
indicator_cross: (f64, f64),
indicator_extent: f64,
traveling: bool,
panel: Size,
area: Size,
scrim_armed: bool,
}
impl NavigationRailWidget {
fn scrim_visible(&self) -> bool {
self.modal && self.expansion() > 0.0
}
fn barrier_active(&self) -> bool {
self.modal && self.expanded
}
fn expansion(&self) -> f64 {
let from = self.width_motion.tween.lerp(0.0);
let to = self.width_motion.target;
let (lo, hi) = (from.min(to), from.max(to));
if hi - lo <= f64::EPSILON {
return if self.expanded { 1.0 } else { 0.0 };
}
((self.width_motion.value - lo) / (hi - lo)).clamp(0.0, 1.0)
}
fn selected_indicator(&mut self) -> Option<Rect> {
let pod = self.items.get_mut(self.selected)?;
let origin = pod.origin();
let rect = pod.widget_mut().downcast_mut::<RailItemWidget>()?.indicator;
Some(Rect::new(
origin.x + rect.x0,
origin.y + rect.y0,
origin.x + rect.x1,
origin.y + rect.y1,
))
}
fn sync_indicator(&mut self) {
let Some(rect) = self.selected_indicator() else {
return;
};
self.indicator_cross = (rect.center().x, rect.width());
self.indicator_extent = rect.height();
let center = rect.center().y;
if !self.ready || self.width_motion.is_animating() {
self.lead.jump(center);
self.trail.jump(center);
self.ready = true;
self.pending_travel = false;
return;
}
if self.pending_travel {
self.lead.travel_to(center, RAIL_LEAD_SPRING);
self.trail.travel_to(center, RAIL_TRAIL_SPRING);
self.pending_travel = false;
}
}
fn pill_rect(&self) -> Rect {
let (cross_center, cross_size) = self.indicator_cross;
let min_main = self.lead.value.min(self.trail.value);
let max_main = self.lead.value.max(self.trail.value);
let extent = (max_main - min_main) + self.indicator_extent;
let start = min_main - self.indicator_extent / 2.0;
Rect::new(
cross_center - cross_size / 2.0,
start,
cross_center + cross_size / 2.0,
start + extent,
)
}
fn route_slots(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
if let Some(pod) = self.menu.as_mut() {
route_event_single(pod, ctx, event);
}
if let Some(pod) = self.fab.as_mut() {
route_event_single(pod, ctx, event);
}
if let Some(pod) = self.trailing.as_mut() {
route_event_single(pod, ctx, event);
}
route_event(&mut self.items, ctx, event);
return EventResult::Ignored;
}
for slot in [&mut self.menu, &mut self.fab, &mut self.trailing] {
if let Some(pod) = slot.as_mut()
&& route_event_single(pod, ctx, event) == EventResult::Handled
{
return EventResult::Handled;
}
}
route_event(&mut self.items, ctx, event)
}
}
impl<State: 'static> View<State> for NavigationRailView<State> {
type Element = NavigationRailWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> NavigationRailWidget {
let expanded = self.rail_type.is_expanded();
let mut items = Vec::new();
let mut sections = Vec::new();
for section in &self.sections {
sections.push(SectionLayout {
first: items.len(),
count: section.destinations.len(),
header: section.header.as_ref().map(TextRun::new),
header_origin: Point::ZERO,
});
for destination in §ion.destinations {
let index = items.len();
items.push(build_item(
destination,
index == self.selected,
expanded,
self.label_behavior,
&self.on_select,
index,
ctx,
));
}
}
NavigationRailWidget {
items,
menu: self.menu_view().map(|view| build_child(&view, ctx)),
fab: self.fab_view().map(|view| build_child(&view, ctx)),
trailing: self.trailing.as_ref().map(|view| build_child(view, ctx)),
sections,
selected: self.selected,
expanded,
modal: self.modality == NavigationRailModality::Modal,
trailing_at_bottom: self.trailing_at_bottom,
background: self.background,
on_dismiss_modal: self.on_dismiss_modal.as_ref().map(erase_callback),
width_motion: WidthMotion::resting(self.target_width()),
lead: TravelAxis::new(),
trail: TravelAxis::new(),
ready: false,
pending_travel: false,
indicator_cross: (0.0, 0.0),
indicator_extent: 0.0,
traveling: false,
panel: Size::ZERO,
area: Size::ZERO,
scrim_armed: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut NavigationRailWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
let expanded = self.rail_type.is_expanded();
let was_expanded = prev.rail_type.is_expanded();
element.on_dismiss_modal = self.on_dismiss_modal.as_ref().map(erase_callback);
element.trailing_at_bottom = self.trailing_at_bottom;
if prev.label_behavior != self.label_behavior {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.background != self.background {
element.background = self.background;
flags |= ChangeFlags::PAINT;
}
if element.modal != (self.modality == NavigationRailModality::Modal) {
element.modal = self.modality == NavigationRailModality::Modal;
element.scrim_armed = false;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.expanded != expanded {
element.expanded = expanded;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let target = self.target_width();
if (element.width_motion.target - target).abs() > f64::EPSILON {
element.width_motion.set_target(target);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.selected != self.selected {
element.selected = self.selected;
element.pending_travel = true;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let prev_dests = prev.destinations();
let next_dests = self.destinations();
let common = prev_dests.len().min(next_dests.len());
for index in 0..common {
let was_selected = index == prev.selected;
let now_selected = index == self.selected;
let pod = &mut element.items[index];
let widget = pod
.widget_mut()
.downcast_mut::<RailItemWidget>()
.expect("a rail destination pod holds a RailItemWidget");
flags |= rebuild_child(
prev_dests[index].active_icon(was_selected),
next_dests[index].active_icon(now_selected),
&mut widget.icon,
ctx,
);
if widget.label.set_content(&next_dests[index].label) {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if widget.badge_count != next_dests[index].badge_count {
widget.badge_count = next_dests[index].badge_count;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
widget.semantic_label = next_dests[index].semantic_label.clone();
if widget.selected != now_selected {
widget.selected = now_selected;
flags |= ChangeFlags::PAINT;
}
if widget.expanded != expanded {
widget.expanded = expanded;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
widget.label_behavior = self.label_behavior;
widget.on_select = item_on_select::<State>(&self.on_select, index);
}
if next_dests.len() > prev_dests.len() {
for (offset, destination) in next_dests[common..].iter().enumerate() {
let index = common + offset;
element.items.push(build_item(
destination,
index == self.selected,
expanded,
self.label_behavior,
&self.on_select,
index,
ctx,
));
}
element.ready = false;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else if next_dests.len() < prev_dests.len() {
for (offset, destination) in prev_dests[common..].iter().enumerate() {
let index = common + offset;
let pod = &mut element.items[index];
if pod.is_active() {
pod.set_active(false);
}
let widget = pod
.widget_mut()
.downcast_mut::<RailItemWidget>()
.expect("a rail destination pod holds a RailItemWidget");
teardown_child(
destination.active_icon(index == prev.selected),
&mut widget.icon,
ctx,
);
}
element.items.truncate(common);
element.ready = false;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let sections_changed = prev.sections.len() != self.sections.len()
|| prev
.sections
.iter()
.zip(self.sections.iter())
.any(|(a, b)| a.destinations.len() != b.destinations.len() || a.header != b.header);
if sections_changed {
let mut first = 0;
element.sections = self
.sections
.iter()
.map(|section| {
let layout = SectionLayout {
first,
count: section.destinations.len(),
header: section.header.as_ref().map(TextRun::new),
header_origin: Point::ZERO,
};
first += section.destinations.len();
layout
})
.collect();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let prev_menu = prev.menu_view();
let next_menu = self.menu_view();
flags |= rebuild_slot(&prev_menu, &next_menu, &mut element.menu, ctx);
if was_expanded != expanded {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let fab_changed = match (&prev.fab, &self.fab) {
(Some(a), Some(b)) => a.shape() != b.shape() || was_expanded != expanded,
(a, b) => a.is_some() != b.is_some(),
};
let prev_fab = prev.fab_view();
let next_fab = self.fab_view();
flags |= rebuild_slot(&prev_fab, &next_fab, &mut element.fab, ctx);
if fab_changed {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags |= rebuild_slot(&prev.trailing, &self.trailing, &mut element.trailing, ctx);
flags
}
fn teardown(&self, element: &mut NavigationRailWidget, ctx: &mut BuildCtx<'_>) {
for (index, destination) in self.destinations().iter().enumerate() {
let Some(pod) = element.items.get_mut(index) else {
continue;
};
let widget = pod
.widget_mut()
.downcast_mut::<RailItemWidget>()
.expect("a rail destination pod holds a RailItemWidget");
teardown_child(
destination.active_icon(index == self.selected),
&mut widget.icon,
ctx,
);
}
if let (Some(view), Some(pod)) = (self.menu_view(), element.menu.as_mut()) {
teardown_child(&view, pod, ctx);
}
if let (Some(view), Some(pod)) = (self.fab_view(), element.fab.as_mut()) {
teardown_child(&view, pod, ctx);
}
if let (Some(view), Some(pod)) = (self.trailing.as_ref(), element.trailing.as_mut()) {
teardown_child(view, pod, ctx);
}
}
}
fn rebuild_slot<State: 'static>(
prev: &Option<AnyView<State>>,
next: &Option<AnyView<State>>,
pod: &mut Option<ChildPod>,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
match (prev, next) {
(Some(prev_view), Some(next_view)) => match pod.as_mut() {
Some(pod) => rebuild_child(prev_view, next_view, pod, ctx),
None => {
*pod = Some(build_child(next_view, ctx));
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
},
(None, Some(next_view)) => {
*pod = Some(build_child(next_view, ctx));
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(Some(prev_view), None) => {
if let Some(mut old) = pod.take() {
teardown_child(prev_view, &mut old, ctx);
}
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(None, None) => ChangeFlags::NONE,
}
}
impl Widget for NavigationRailWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let area = Size::new(
finite_or_zero(bc.max().width),
finite_or_zero(bc.max().height),
);
let panel_width = self.width_motion.value.max(0.0).min(area.width.max(0.0));
self.area = area;
self.panel = Size::new(panel_width, area.height);
let inner = (panel_width - HORIZONTAL_INSET * 2.0).max(0.0);
let mut y = TOP_GAP;
if let Some(pod) = self.menu.as_mut() {
let size =
pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, f64::INFINITY)));
let x = if self.expanded {
HORIZONTAL_INSET
} else {
HORIZONTAL_INSET + (inner - size.width) / 2.0
};
pod.set_origin(Point::new(x.max(0.0), y));
y += size.height + SECTION_PADDING_BOTTOM;
}
if let Some(pod) = self.fab.as_mut() {
let size =
pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, f64::INFINITY)));
let x = if self.expanded {
HORIZONTAL_INSET
} else {
HORIZONTAL_INSET + (inner - size.width) / 2.0
};
pod.set_origin(Point::new(x.max(0.0), y));
y += size.height + SECTION_PADDING_BOTTOM;
}
let expanded = self.expanded;
let mut sections = std::mem::take(&mut self.sections);
for section in &mut sections {
if expanded && let Some(header) = section.header.as_mut() {
let theme = Theme::from_layout_ctx(ctx);
let style = type_style(theme.map(|t| &t.type_scale.title_small), TITLE_SMALL);
y += SECTION_HEADER_SPACING_TOP;
let size = header.shape(ctx, &style, Some(inner));
section.header_origin = Point::new(HORIZONTAL_INSET, y);
y += size.height + SECTION_HEADER_SPACING_BOTTOM;
}
for index in section.first..section.first + section.count {
let Some(pod) = self.items.get_mut(index) else {
continue;
};
y += ITEM_VERTICAL_GAP;
let size = pod.layout_child(
ctx,
&BoxConstraints::new(Size::new(inner, 0.0), Size::new(inner, f64::INFINITY)),
);
pod.set_origin(Point::new(HORIZONTAL_INSET, y));
y += size.height + ITEM_VERTICAL_GAP;
}
}
self.sections = sections;
if let Some(pod) = self.trailing.as_mut() {
let size =
pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, f64::INFINITY)));
let x = if expanded {
HORIZONTAL_INSET
} else {
HORIZONTAL_INSET + (inner - size.width) / 2.0
};
let top = if self.trailing_at_bottom {
(area.height - SECTION_PADDING_BOTTOM - size.height).max(y)
} else {
y
};
pod.set_origin(Point::new(x.max(0.0), top));
}
self.sync_indicator();
let size = if self.modal { area } else { self.panel };
bc.constrain(size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let container = self
.background
.unwrap_or_else(|| theme.map_or(SURFACE, |t| t.scheme().surface));
let scrim_base = theme.map_or(SCRIM, |t| t.scheme().scrim);
let indicator_fill = theme.map_or(SECONDARY_CONTAINER, |t| t.scheme().secondary_container);
let header_ink = theme.map_or(ON_SURFACE_VARIANT, |t| t.scheme().on_surface_variant);
let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
let now = ctx.frame_time();
let width_changed = if reduce_motion {
self.width_motion.snap()
} else {
let before = self.width_motion.value;
self.width_motion.advance(now) || self.width_motion.value != before
};
if width_changed {
ctx.request_layout();
}
if reduce_motion {
self.lead.jump(self.lead.target);
self.trail.jump(self.trail.target);
} else {
let lead = self.lead.advance(now);
let trail = self.trail.advance(now);
if lead || trail {
ctx.request_frame();
}
}
self.traveling = self.lead.is_animating() || self.trail.is_animating();
let origin = ctx.origin();
if self.scrim_visible() {
let alpha = OVERLAY_SCRIM_ALPHA * self.expansion() as f32;
scene.fill_rect(origin, self.area, with_alpha(scrim_base, alpha));
}
if self.panel.width > 0.0 {
scene.fill_rect(origin, self.panel, container);
}
if self.traveling && self.ready {
let pill = self.pill_rect();
scene.fill_rounded_rect(
Point::new(origin.x + pill.x0, origin.y + pill.y0),
pill.size(),
pill.width().min(pill.height()) / 2.0,
indicator_fill,
);
}
for slot in [&mut self.menu, &mut self.fab] {
if let Some(pod) = slot.as_mut() {
pod.paint_child(ctx, scene);
}
}
let traveling = self.traveling;
for pod in &mut self.items {
if let Some(widget) = pod.widget_mut().downcast_mut::<RailItemWidget>() {
widget.use_local_indicator = !traveling;
}
pod.paint_child(ctx, scene);
}
if self.expanded {
for section in &self.sections {
if let Some(header) = §ion.header {
header.paint(
Point::new(
origin.x + section.header_origin.x,
origin.y + section.header_origin.y,
),
header_ink,
scene,
);
}
}
}
if let Some(pod) = self.trailing.as_mut() {
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
return self.route_slots(ctx, event);
}
let InputEvent::Pointer(p) = event else {
return self.route_slots(ctx, event);
};
if !self.barrier_active() {
return self.route_slots(ctx, event);
}
if p.position.x < self.panel.width {
self.route_slots(ctx, event);
return EventResult::Handled;
}
match p.phase {
PointerPhase::Down => {
if presses(p) {
self.scrim_armed = true;
ctx.capture_pointer();
}
EventResult::Handled
}
PointerPhase::Up => {
if self.scrim_armed
&& p.position.x >= self.panel.width
&& let Some(on_dismiss) = self.on_dismiss_modal.as_mut()
{
(on_dismiss)(ctx);
}
self.scrim_armed = false;
EventResult::Handled
}
PointerPhase::Cancel => {
self.scrim_armed = false;
EventResult::Handled
}
PointerPhase::Move => EventResult::Handled,
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::TabList,
|_| {},
|ctx| {
if let Some(pod) = &self.menu {
pod.semantics_child(ctx);
}
if let Some(pod) = &self.fab {
pod.semantics_child(ctx);
}
for pod in &self.items {
pod.semantics_child(ctx);
}
if let Some(pod) = &self.trailing {
pod.semantics_child(ctx);
}
},
);
}
visit_children!(menu, fab, items, trailing);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::text::TextContext;
use frust::authoring::{PointerButton, PointerEvent};
use std::any::Any;
const AREA: Size = Size::new(400.0, 600.0);
fn dest<State: 'static>(label: &str) -> RailDestination<State> {
rail_destination(icon(crate::icons::HOME), label)
}
fn one_section<State: 'static>() -> Vec<RailSection<State>> {
vec![rail_section(vec![
dest("Home"),
dest("Search"),
dest("Profile"),
])]
}
fn rail<State: 'static>() -> NavigationRailView<State> {
navigation_rail(one_section(), 0, |_state: &mut State, _index| {})
}
fn build<State: 'static>(view: &NavigationRailView<State>) -> NavigationRailWidget {
let mut counter = 0u64;
View::<State>::build(view, &mut BuildCtx::new(&mut counter))
}
fn rebuild<State: 'static>(
next: &NavigationRailView<State>,
prev: &NavigationRailView<State>,
widget: &mut NavigationRailWidget,
) -> ChangeFlags {
let mut counter = 1u64;
View::<State>::rebuild(next, prev, widget, &mut BuildCtx::new(&mut counter))
}
fn layout_themed(widget: &mut NavigationRailWidget, theme: Option<&Theme>, area: Size) -> Size {
let mut tcx = TextContext::new();
let mut ctx =
LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
widget.layout(&mut ctx, &BoxConstraints::loose(area))
}
fn layout(widget: &mut NavigationRailWidget) -> Size {
layout_themed(widget, None, AREA)
}
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
rrects: Vec<(Point, Size, f64, Color)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
self.rects.push((origin, size, color));
}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
self.rrects.push((origin, size, radius, color));
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
}
fn paint_at(
widget: &mut NavigationRailWidget,
t_ms: u64,
theme: Option<&Theme>,
) -> (Recorder, bool) {
let mut rec = Recorder::default();
let mut ctx =
PaintCtx::for_test(Point::ZERO, AREA, FrameTime::from_nanos(t_ms * 1_000_000));
if let Some(theme) = theme {
ctx = ctx.with_theme(theme);
}
widget.paint(&mut ctx, &mut rec);
let needs_layout = ctx.needs_layout();
(rec, needs_layout)
}
fn dispatch(
widget: &mut NavigationRailWidget,
state: &mut dyn Any,
phase: PointerPhase,
pos: Point,
) -> EventResult {
let event = InputEvent::Pointer(PointerEvent {
phase,
position: pos,
button: PointerButton::Primary,
});
let mut ctx = EventCtx::new(state, Point::ZERO, AREA);
widget.event(&mut ctx, &event)
}
fn item(widget: &mut NavigationRailWidget, index: usize) -> &mut RailItemWidget {
widget.items[index]
.widget_mut()
.downcast_mut::<RailItemWidget>()
.expect("a rail destination pod holds a RailItemWidget")
}
fn tap(widget: &mut NavigationRailWidget, state: &mut dyn Any, pos: Point) {
dispatch(widget, state, PointerPhase::Down, pos);
dispatch(widget, state, PointerPhase::Up, pos);
}
#[test]
fn collapsed_geometry_pins_the_rail_width_and_its_item_boxes() {
let view: NavigationRailView<()> = rail().rail_type(NavigationRailType::Collapsed);
let mut w = build(&view);
let size = layout(&mut w);
assert_eq!(
size,
Size::new(RAIL_COLLAPSED_WIDTH, AREA.height),
"a standard rail is exactly its own width by the height it was given"
);
let inner = RAIL_COLLAPSED_WIDTH - HORIZONTAL_INSET * 2.0;
for index in 0..3 {
let pod = &w.items[index];
assert_eq!(pod.origin().x, HORIZONTAL_INSET);
assert_eq!(pod.size(), Size::new(inner, RAIL_ITEM_COLLAPSED_HEIGHT));
}
let spacing = w.items[1].origin().y - w.items[0].origin().y;
assert_eq!(
spacing,
RAIL_ITEM_COLLAPSED_HEIGHT + ITEM_VERTICAL_GAP * 2.0
);
let menu = w
.menu
.as_ref()
.expect("a collapsed rail carries a menu button");
assert_eq!(menu.origin().y, TOP_GAP);
assert!(w.items[0].origin().y > menu.origin().y + menu.size().height);
let chip = item(&mut w, 0).indicator;
assert_eq!(chip.x0, 0.0);
assert_eq!(chip.width(), CHIP_WIDTH);
assert_eq!(chip.height(), CHIP_HEIGHT);
}
#[test]
fn expanded_geometry_pins_the_rail_width_and_makes_the_row_the_indicator() {
let view: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
let mut w = build(&view);
let size = layout(&mut w);
assert_eq!(size, Size::new(RAIL_EXPANDED_MIN_WIDTH, AREA.height));
let inner = RAIL_EXPANDED_MIN_WIDTH - HORIZONTAL_INSET * 2.0;
let pod = &w.items[0];
assert_eq!(pod.origin().x, HORIZONTAL_INSET);
assert_eq!(pod.size(), Size::new(inner, RAIL_ITEM_EXPANDED_HEIGHT));
let widget = item(&mut w, 0);
assert_eq!(
widget.indicator,
Rect::new(0.0, 0.0, inner, RAIL_ITEM_EXPANDED_HEIGHT),
"an expanded row *is* its own selection indicator"
);
assert_eq!(widget.icon.origin().x, INDICATOR_LEADING);
assert_eq!(
widget.label_origin.x,
INDICATOR_LEADING + ICON_SIZE + ICON_LABEL_GAP
);
}
#[test]
fn the_expanded_width_clamps_to_the_reference_band() {
let wide: NavigationRailView<()> = rail()
.rail_type(NavigationRailType::Expanded)
.expanded_width(1000.0);
assert_eq!(wide.target_width(), RAIL_EXPANDED_MAX_WIDTH);
let narrow: NavigationRailView<()> = rail()
.rail_type(NavigationRailType::Expanded)
.expanded_width(10.0);
assert_eq!(narrow.target_width(), RAIL_EXPANDED_MIN_WIDTH);
let default: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
assert_eq!(default.target_width(), RAIL_EXPANDED_MIN_WIDTH);
}
#[test]
fn hide_when_collapsed_collapses_the_rail_to_zero_width() {
let view: NavigationRailView<()> = rail()
.rail_type(NavigationRailType::Collapsed)
.hide_when_collapsed(true);
let mut w = build(&view);
assert_eq!(layout(&mut w), Size::new(0.0, AREA.height));
}
#[test]
fn trailing_pins_to_the_bottom_edge_by_default_and_follows_the_items_otherwise() {
let bottom: NavigationRailView<()> = rail()
.rail_type(NavigationRailType::Expanded)
.trailing(icon(crate::icons::SEARCH));
let mut w = build(&bottom);
layout(&mut w);
let pod = w.trailing.as_ref().expect("a trailing slot");
assert_eq!(
pod.origin().y + pod.size().height + SECTION_PADDING_BOTTOM,
AREA.height
);
let inline: NavigationRailView<()> = rail()
.rail_type(NavigationRailType::Expanded)
.trailing(icon(crate::icons::SEARCH))
.trailing_at_bottom(false);
let mut w = build(&inline);
layout(&mut w);
let last_item = w.items.last().expect("three destinations");
let bottom_of_items = last_item.origin().y + last_item.size().height;
let pod = w.trailing.as_ref().expect("a trailing slot");
assert!(pod.origin().y < bottom_of_items + RAIL_ITEM_EXPANDED_HEIGHT);
}
#[test]
fn a_type_change_ramps_the_width_and_paint_requests_layout_every_frame() {
let collapsed: NavigationRailView<()> = rail().rail_type(NavigationRailType::Collapsed);
let mut w = build(&collapsed);
assert_eq!(layout(&mut w).width, RAIL_COLLAPSED_WIDTH);
let (_, settled) = paint_at(&mut w, 0, None);
assert!(!settled, "a resting rail requests no layout");
let expanded: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
let flags = rebuild(&expanded, &collapsed, &mut w);
assert!(flags.needs_layout());
let mut widths = vec![layout(&mut w).width];
let mut t_ms = 0u64;
let mut frames = 0u32;
loop {
let (_, needs_layout) = paint_at(&mut w, t_ms, None);
if !needs_layout {
break;
}
frames += 1;
widths.push(layout(&mut w).width);
t_ms += 16;
assert!(frames < 200, "a 280ms ramp settles well inside 200 frames");
}
assert!(frames > 1, "the ramp spans more than one frame");
assert!(
widths
.windows(2)
.all(|pair| pair[1] >= pair[0] - f64::EPSILON),
"the width rises monotonically: {widths:?}"
);
assert_eq!(
widths.last().copied(),
Some(RAIL_EXPANDED_MIN_WIDTH),
"the settled ramp lands exactly on the expanded width"
);
let (_, still) = paint_at(&mut w, t_ms + 16, None);
assert!(!still, "settled: no more layout requests");
}
#[test]
fn reduce_motion_snaps_the_width_and_requests_exactly_one_relayout() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let collapsed: NavigationRailView<()> = rail().rail_type(NavigationRailType::Collapsed);
let mut w = build(&collapsed);
layout_themed(&mut w, Some(&theme), AREA);
let expanded: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
rebuild(&expanded, &collapsed, &mut w);
let (_, needs_layout) = paint_at(&mut w, 0, Some(&theme));
assert_eq!(w.width_motion.value, RAIL_EXPANDED_MIN_WIDTH, "snapped");
assert!(
needs_layout,
"the snap changed a layout value, so it must request one relayout"
);
assert_eq!(
layout_themed(&mut w, Some(&theme), AREA).width,
RAIL_EXPANDED_MIN_WIDTH,
"the rail reaches its target on the snap frame, not some later one"
);
let (_, still) = paint_at(&mut w, 16, Some(&theme));
assert!(
!still,
"a snap requests exactly one relayout, never an ongoing frame request"
);
}
#[test]
fn a_tap_on_a_destination_reports_its_flat_index_without_self_mutating() {
let view: NavigationRailView<Vec<usize>> = navigation_rail(
vec![
rail_section(vec![dest("Home"), dest("Search")]),
rail_section(vec![dest("Profile")]).header("Account"),
],
0,
|state: &mut Vec<usize>, index| state.push(index),
)
.rail_type(NavigationRailType::Expanded);
let mut w = build(&view);
layout(&mut w);
let mut log: Vec<usize> = Vec::new();
let pod = &w.items[2];
let pos = Point::new(
pod.origin().x + 10.0,
pod.origin().y + pod.size().height / 2.0,
);
tap(&mut w, &mut log, pos);
assert_eq!(log, vec![2]);
assert_eq!(w.selected, 0, "the rail never moves its own selection");
}
#[test]
fn the_menu_button_reports_the_toggled_type_without_self_mutating() {
let view: NavigationRailView<Vec<NavigationRailType>> = navigation_rail(
one_section(),
0,
|_state: &mut Vec<NavigationRailType>, _index| {},
)
.rail_type(NavigationRailType::Expanded)
.on_type_changed(|state: &mut Vec<NavigationRailType>, next| state.push(next));
let mut w = build(&view);
layout(&mut w);
let mut log: Vec<NavigationRailType> = Vec::new();
let pod = w
.menu
.as_ref()
.expect("an expanded rail carries a menu button");
let pos = Point::new(
pod.origin().x + pod.size().width / 2.0,
pod.origin().y + pod.size().height / 2.0,
);
tap(&mut w, &mut log, pos);
assert_eq!(log, vec![NavigationRailType::Collapsed]);
assert!(w.expanded, "the rail never collapses itself");
}
#[test]
fn the_always_variants_carry_no_menu_button() {
for rail_type in [
NavigationRailType::AlwaysExpand,
NavigationRailType::AlwaysCollapse,
] {
let view: NavigationRailView<()> = rail().rail_type(rail_type);
let w = build(&view);
assert!(w.menu.is_none(), "{rail_type:?} offers no toggle");
}
for rail_type in [NavigationRailType::Expanded, NavigationRailType::Collapsed] {
let view: NavigationRailView<()> = rail().rail_type(rail_type);
let w = build(&view);
assert!(w.menu.is_some(), "{rail_type:?} offers a toggle");
}
}
#[test]
fn a_modal_rail_fills_its_area_and_paints_a_scrim_beside_its_panel() {
let view: NavigationRailView<()> = rail()
.rail_type(NavigationRailType::Expanded)
.modality(NavigationRailModality::Modal);
let mut w = build(&view);
assert_eq!(
layout(&mut w),
AREA,
"a modal rail is a full-area host, not a layout column"
);
let (rec, _) = paint_at(&mut w, 0, None);
let scrim = rec
.rects
.iter()
.find(|(_, size, _)| *size == AREA)
.expect("the scrim covers the whole area");
assert!(
(scrim.2.components[3] - OVERLAY_SCRIM_ALPHA).abs() < 1e-6,
"the scrim paints at the M3 32%"
);
assert!(
rec.rects
.iter()
.any(|(_, size, _)| *size == Size::new(RAIL_EXPANDED_MIN_WIDTH, AREA.height)),
"the panel is pinned to the leading edge at the rail's own width"
);
let standard: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
let mut w = build(&standard);
layout(&mut w);
let (rec, _) = paint_at(&mut w, 0, None);
assert!(rec.rects.iter().all(|(_, size, _)| *size != AREA));
}
#[test]
fn a_scrim_press_reports_the_dismiss_request_and_never_reaches_the_page() {
let view: NavigationRailView<u32> =
navigation_rail(one_section(), 0, |_state: &mut u32, _index| {})
.rail_type(NavigationRailType::Expanded)
.modality(NavigationRailModality::Modal)
.on_dismiss_modal(|state: &mut u32| *state += 1);
let mut w = build(&view);
layout(&mut w);
let mut dismissed = 0u32;
let on_scrim = Point::new(RAIL_EXPANDED_MIN_WIDTH + 40.0, 200.0);
assert_eq!(
dispatch(&mut w, &mut dismissed, PointerPhase::Down, on_scrim),
EventResult::Handled,
"the barrier swallows the press rather than letting the page take it"
);
assert_eq!(dismissed, 0, "nothing fires on the way down");
dispatch(&mut w, &mut dismissed, PointerPhase::Up, on_scrim);
assert_eq!(dismissed, 1, "fire-on-up-inside, like every other press");
dispatch(&mut w, &mut dismissed, PointerPhase::Down, on_scrim);
dispatch(&mut w, &mut dismissed, PointerPhase::Cancel, on_scrim);
assert_eq!(dismissed, 1);
let on_panel = Point::new(RAIL_EXPANDED_MIN_WIDTH - 4.0, AREA.height - 4.0);
assert_eq!(
dispatch(&mut w, &mut dismissed, PointerPhase::Down, on_panel),
EventResult::Handled
);
dispatch(&mut w, &mut dismissed, PointerPhase::Up, on_panel);
assert_eq!(dismissed, 1, "a press on the panel is not a dismiss");
}
#[test]
fn a_collapsed_modal_rail_lets_presses_outside_its_panel_through() {
let view: NavigationRailView<u32> =
navigation_rail(one_section(), 0, |_state: &mut u32, _index| {})
.rail_type(NavigationRailType::Collapsed)
.modality(NavigationRailModality::Modal)
.on_dismiss_modal(|state: &mut u32| *state += 1);
let mut w = build(&view);
layout(&mut w);
let mut dismissed = 0u32;
let beside = Point::new(RAIL_COLLAPSED_WIDTH + 40.0, 200.0);
assert_eq!(
dispatch(&mut w, &mut dismissed, PointerPhase::Down, beside),
EventResult::Ignored,
"nothing behind a collapsed modal rail goes inert"
);
assert_eq!(dismissed, 0);
}
#[test]
fn a_section_header_takes_space_only_while_expanded() {
let headed = || {
vec![
rail_section(vec![dest::<()>("Home")]).header("Main"),
rail_section(vec![dest::<()>("Profile")]).header("Account"),
]
};
let bare = || {
vec![
rail_section(vec![dest::<()>("Home")]),
rail_section(vec![dest::<()>("Profile")]),
]
};
let mut expanded_headed = build(&navigation_rail(headed(), 0, |_s: &mut (), _i| {}));
let mut expanded_bare = build(&navigation_rail(bare(), 0, |_s: &mut (), _i| {}));
layout(&mut expanded_headed);
layout(&mut expanded_bare);
assert!(
expanded_headed.items[0].origin().y > expanded_bare.items[0].origin().y,
"an expanded rail reserves space above its first destination for the header"
);
assert!(
expanded_headed.sections[1].header_origin.y > 0.0,
"every section's header is placed"
);
let mut collapsed_headed = build(
&navigation_rail(headed(), 0, |_s: &mut (), _i| {})
.rail_type(NavigationRailType::Collapsed),
);
let mut collapsed_bare = build(
&navigation_rail(bare(), 0, |_s: &mut (), _i| {})
.rail_type(NavigationRailType::Collapsed),
);
layout(&mut collapsed_headed);
layout(&mut collapsed_bare);
assert_eq!(
collapsed_headed.items[1].origin().y,
collapsed_bare.items[1].origin().y,
"a collapsed rail flattens every section and drops the headers"
);
}
#[test]
fn the_fab_slot_renders_per_type_and_fires() {
let collapsed: NavigationRailView<u32> =
navigation_rail(one_section(), 0, |_state: &mut u32, _index| {})
.rail_type(NavigationRailType::Collapsed)
.fab(rail_fab(crate::icons::ADD, "Compose", |state: &mut u32| {
*state += 1
}));
let mut w = build(&collapsed);
layout(&mut w);
let pod = w.fab.as_ref().expect("the FAB slot renders");
let square = pod.size();
assert_eq!(
square.width, square.height,
"a collapsed rail shows the plain square FAB"
);
let mut pressed = 0u32;
let pos = Point::new(
pod.origin().x + square.width / 2.0,
pod.origin().y + square.height / 2.0,
);
tap(&mut w, &mut pressed, pos);
assert_eq!(pressed, 1);
let expanded: NavigationRailView<u32> =
navigation_rail(one_section(), 0, |_state: &mut u32, _index| {})
.rail_type(NavigationRailType::Expanded)
.fab(rail_fab(crate::icons::ADD, "Compose", |state: &mut u32| {
*state += 1
}));
let mut w = build(&expanded);
let mut t_ms = 0u64;
for _ in 0..40 {
layout(&mut w);
paint_at(&mut w, t_ms, None);
t_ms += 16;
}
let pill = w.fab.as_ref().expect("the FAB slot renders").size();
assert!(
pill.width > pill.height,
"an expanded rail shows the extended (labeled) FAB, got {pill:?}"
);
}
#[test]
fn the_label_behavior_gates_a_collapsed_label_but_never_an_expanded_one() {
for (behavior, selected_shows, unselected_shows) in [
(RailLabelBehavior::AlwaysShow, true, true),
(RailLabelBehavior::OnlySelected, true, false),
(RailLabelBehavior::AlwaysHide, false, false),
] {
let view: NavigationRailView<()> = rail()
.rail_type(NavigationRailType::Collapsed)
.label_behavior(behavior);
let mut w = build(&view);
layout(&mut w);
assert_eq!(item(&mut w, 0).shows_label(), selected_shows);
assert_eq!(item(&mut w, 1).shows_label(), unselected_shows);
let view: NavigationRailView<()> = rail()
.rail_type(NavigationRailType::Expanded)
.label_behavior(behavior);
let mut w = build(&view);
layout(&mut w);
assert!(
item(&mut w, 1).shows_label(),
"an expanded row always shows its label, whatever {behavior:?} says"
);
}
}
#[test]
fn a_badge_count_clamps_to_the_documented_three_digit_cap() {
let view: NavigationRailView<()> = navigation_rail(
vec![rail_section(vec![
dest("None"),
dest("Dot").badge_count(0),
dest("Small").badge_count(7),
dest("Huge").badge_count(5_000),
])],
0,
|_s: &mut (), _i| {},
);
let mut w = build(&view);
layout(&mut w);
assert_eq!(item(&mut w, 0).badge_text(), None);
assert_eq!(item(&mut w, 1).badge_text(), None, "a zero count is a dot");
assert_eq!(item(&mut w, 1).badge_size, Size::new(BADGE_DOT, BADGE_DOT));
assert_eq!(item(&mut w, 2).badge_text().as_deref(), Some("7"));
assert_eq!(item(&mut w, 3).badge_text().as_deref(), Some("999+"));
assert_eq!(item(&mut w, 0).badge_size, Size::ZERO);
}
#[test]
fn the_indicator_springs_match_the_reference_motion() {
let reference = crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT;
assert_eq!(RAIL_LEAD_SPRING.stiffness, reference.stiffness);
assert_eq!(RAIL_TRAIL_SPRING.stiffness, reference.stiffness);
assert_eq!(RAIL_LEAD_SPRING.mass, 1.0);
assert_eq!(RAIL_TRAIL_SPRING.mass, 1.0);
assert_eq!(RAIL_LEAD_SPRING.damping_ratio, 0.45);
assert_eq!(RAIL_TRAIL_SPRING.damping_ratio, 0.55);
}
#[test]
fn an_interrupted_retarget_carries_the_edges_real_velocity() {
let mut axis = TravelAxis::new();
axis.travel_to(200.0, RAIL_LEAD_SPRING);
let mut now = FrameTime::from_nanos(0);
for step in 1..=3u64 {
now = FrameTime::from_nanos(step * 16_000_000);
assert!(axis.advance(now), "the leg should still be in flight");
}
let pre_retarget_velocity = axis.velocity;
assert!(
pre_retarget_velocity.abs() > 10.0,
"a few frames into a 200px travel should already be moving well \
above a near-zero relaunch rate, got {pre_retarget_velocity}"
);
axis.travel_to(0.0, RAIL_LEAD_SPRING);
let carried = axis
.flight
.expect("travel_to launches a fresh leg")
.velocity(0.0);
assert!(
(carried - pre_retarget_velocity).abs() < 1e-6,
"retarget must carry the interrupted leg's real velocity into the \
new spring's initial condition, got carried={carried} \
pre_retarget={pre_retarget_velocity}"
);
axis.advance(now);
let value_before_retarget = axis.value;
let next = FrameTime::from_nanos(now.as_nanos() + 16_000_000);
axis.advance(next);
let post_retarget_displacement = (axis.value - value_before_retarget).abs();
assert!(
post_retarget_displacement > 0.5,
"the first post-retarget frame should move visibly, matching the \
carried rate rather than pausing/kinking near zero, got \
displacement={post_retarget_displacement}"
);
}
#[test]
fn a_selection_change_travels_the_pill_then_hands_it_back_to_the_item() {
let first: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
let mut w = build(&first);
layout(&mut w);
let (rec, _) = paint_at(&mut w, 0, None);
assert!(!w.traveling, "a settled rail paints no bridge");
assert!(
item(&mut w, 0).use_local_indicator,
"the selected item owns its own resting pill"
);
let resting = w.items[0].size();
assert!(
rec.rrects.iter().any(|(_, size, _, _)| *size == resting),
"the resting indicator is the selected row itself"
);
let moved: NavigationRailView<()> = navigation_rail(one_section(), 2, |_s: &mut (), _i| {})
.rail_type(NavigationRailType::Expanded);
rebuild(&moved, &first, &mut w);
layout(&mut w);
let start = w.items[0].origin().y + w.items[0].size().height / 2.0;
let end = w.items[2].origin().y + w.items[2].size().height / 2.0;
let mut t_ms = 0u64;
let mut frames = 0u32;
let mut stretched = false;
loop {
paint_at(&mut w, t_ms, None);
if !w.traveling {
break;
}
assert!(
!item(&mut w, 2).use_local_indicator,
"while the bridge travels, no item paints its own fill"
);
let pill = w.pill_rect();
stretched |= pill.height() > w.indicator_extent + 1.0;
assert!(
pill.center().y >= start - 40.0 && pill.center().y <= end + 40.0,
"the bridge stays between the two destinations"
);
frames += 1;
t_ms += 16;
assert!(frames < 400, "the travel settles well inside 400 frames");
}
assert!(frames > 1, "the travel spans more than one frame");
assert!(
stretched,
"the lead runs ahead of the trail — the pill elongates into a bridge"
);
assert!(
(w.pill_rect().center().y - end).abs() < 1e-6,
"the settled pill lands on the newly selected destination"
);
assert!(
item(&mut w, 2).use_local_indicator,
"once settled the item takes its resting pill back"
);
}
#[test]
fn a_width_transition_jumps_the_pill_instead_of_morphing_it() {
let collapsed: NavigationRailView<()> =
navigation_rail(one_section(), 1, |_s: &mut (), _i| {})
.rail_type(NavigationRailType::Collapsed);
let mut w = build(&collapsed);
layout(&mut w);
let expanded: NavigationRailView<()> =
navigation_rail(one_section(), 1, |_s: &mut (), _i| {})
.rail_type(NavigationRailType::Expanded);
rebuild(&expanded, &collapsed, &mut w);
let mut t_ms = 0u64;
for _ in 0..40 {
let (_, needs_layout) = paint_at(&mut w, t_ms, None);
assert!(
!w.traveling,
"a width transition remeasures the pill, it never morphs it"
);
if !needs_layout {
break;
}
layout(&mut w);
let center = w.items[1].origin().y + w.items[1].size().height / 2.0;
assert!(
(w.pill_rect().center().y - center).abs() < 1e-6,
"the pill tracks the selected destination through the ramp"
);
t_ms += 16;
}
}
#[test]
fn growing_and_shrinking_the_destination_list_reconciles() {
let two: NavigationRailView<()> = navigation_rail(
vec![rail_section(vec![dest("Home"), dest("Search")])],
0,
|_s: &mut (), _i| {},
);
let mut w = build(&two);
layout(&mut w);
assert_eq!(w.items.len(), 2);
let three: NavigationRailView<()> = rail();
let flags = rebuild(&three, &two, &mut w);
assert_eq!(w.items.len(), 3);
assert!(flags.needs_layout());
let one: NavigationRailView<()> = navigation_rail(
vec![rail_section(vec![dest("Home")])],
0,
|_s: &mut (), _i| {},
);
let flags = rebuild(&one, &three, &mut w);
assert_eq!(w.items.len(), 1);
assert!(flags.needs_layout());
layout(&mut w);
}
#[test]
fn a_rebuild_adopts_the_new_selection_and_labels() {
let first: NavigationRailView<()> = rail();
let mut w = build(&first);
layout(&mut w);
assert!(item(&mut w, 0).selected);
let second: NavigationRailView<()> = navigation_rail(
vec![rail_section(vec![
dest("Home"),
dest("Explore"),
dest("Profile"),
])],
1,
|_s: &mut (), _i| {},
);
let flags = rebuild(&second, &first, &mut w);
assert!(flags.needs_paint());
assert!(!item(&mut w, 0).selected);
assert!(item(&mut w, 1).selected);
assert_eq!(item(&mut w, 1).label.content, "Explore");
}
#[test]
fn semantics_yields_a_tablist_of_tabs_with_selection() {
fn logic(_state: &mut ()) -> NavigationRailView<()> {
navigation_rail(one_section(), 1, |_s: &mut (), _i| {})
.rail_type(NavigationRailType::Expanded)
}
let mut root: frust_core::RenderRoot<(), NavigationRailView<()>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
let update = root.semantics();
let tablist = update
.nodes
.iter()
.find(|(_, node)| node.role() == Role::TabList)
.expect("a TabList container node is contributed");
assert!(
tablist.1.children().len() >= 3,
"every destination is forwarded beneath it"
);
let tabs: Vec<_> = update
.nodes
.iter()
.filter(|(_, node)| node.role() == Role::Tab)
.collect();
assert_eq!(tabs.len(), 3);
let selected = tabs
.iter()
.find(|(_, node)| node.label() == Some("Search"))
.expect("the Search tab is present");
assert_eq!(selected.1.is_selected(), Some(true));
let unselected = tabs
.iter()
.find(|(_, node)| node.label() == Some("Home"))
.expect("the Home tab is present");
assert_eq!(unselected.1.is_selected(), Some(false));
}
}