use std::rc::Rc;
use frust::authoring::text::{
FontWeight, LineHeight, TextContext, TextLayout, TextOverflow, TextStyle,
};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
PointerPhase, Role, SemanticsCtx, View, Widget, build_child, rebuild_child, route_event,
teardown_child, visit_children,
};
use frust::{FrameTime, NavigatorController, PopResult, Spring, SpringDesc, Theme};
use kurbo::{Point, Rect, Size, Vec2};
use peniko::{Brush, Color};
use crate::overlay::{
OverlayExtent, OverlayLimit, OverlayModalConfig, OverlayModalContent, OverlayModalView,
OverlayModalWidget, OverlaySide, overlay_modal, show_overlay_modal,
};
use crate::press::presses;
use crate::tokens::MaterialSpacing;
pub const DRAWER_WIDTH: f64 = 360.0;
const DESTINATION_HEIGHT: f64 = 56.0;
const HEADLINE_PAD_H: f64 = 28.0;
const HEADLINE_PAD_V: f64 = 16.0;
const ICON_SIZE: f64 = 24.0;
const DESTINATION_PAD_H: f64 = MaterialSpacing::MD;
const DESTINATION_PAD_V: f64 = 2.0;
const DESTINATION_INNER_PAD_H: f64 = MaterialSpacing::LG;
const ICON_LABEL_GAP: f64 = MaterialSpacing::MD;
const ROW_OUTER_HEIGHT: f64 = DESTINATION_HEIGHT + 2.0 * DESTINATION_PAD_V;
const DIVIDER_THICKNESS: f64 = 1.0;
const HEADER_ROW_HEIGHT: f64 = 20.0 + 2.0 * HEADLINE_PAD_V;
const BADGE_DOT: f64 = 8.0;
const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
const TITLE_SMALL: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
const OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
const PILL_REST_EPSILON: f64 = 0.01;
const GEOMETRY_EPSILON: f64 = 0.5;
#[derive(Clone, Copy, Debug)]
struct Edge {
desc: SpringDesc,
target: f64,
flight: Option<Spring>,
elapsed: f64,
position: f64,
velocity: f64,
}
impl Edge {
const fn new(desc: SpringDesc) -> Self {
Edge {
desc,
target: 0.0,
flight: None,
elapsed: 0.0,
position: 0.0,
velocity: 0.0,
}
}
fn snap(&mut self, to: f64) {
self.target = to;
self.position = to;
self.velocity = 0.0;
self.flight = None;
self.elapsed = 0.0;
}
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;
}
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
}
fn settle(&mut self) {
self.position = self.target;
self.velocity = 0.0;
self.flight = None;
self.elapsed = 0.0;
}
fn is_animating(&self) -> bool {
self.flight.is_some()
}
fn is_at_geometry(&self, to: f64) -> bool {
(self.target - to).abs() < GEOMETRY_EPSILON
}
}
struct DrawerIndicator {
lead: Edge,
trail: Edge,
last_time: Option<FrameTime>,
rest: Rect,
ready: bool,
}
impl DrawerIndicator {
fn new() -> Self {
DrawerIndicator {
lead: Edge::new(crate::navbar::LEAD_SPRING),
trail: Edge::new(crate::navbar::TRAIL_SPRING),
last_time: None,
rest: Rect::ZERO,
ready: false,
}
}
fn sync(&mut self, rest: Rect, travel: bool) {
self.rest = rest;
let center = rest.center().y;
if !self.ready {
self.lead.snap(center);
self.trail.snap(center);
self.last_time = None;
self.ready = true;
return;
}
if !travel {
if self.lead.is_at_geometry(center) && self.trail.is_at_geometry(center) {
return;
}
self.lead.snap(center);
self.trail.snap(center);
self.last_time = None;
return;
}
if (self.lead.target - center).abs() < GEOMETRY_EPSILON && !self.is_animating() {
return;
}
self.lead.retarget(center);
self.trail.retarget(center);
self.last_time = None;
}
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
}
fn settle(&mut self) {
self.lead.settle();
self.trail.settle();
}
fn is_animating(&self) -> bool {
self.lead.is_animating() || self.trail.is_animating()
}
fn pill_rect(&self) -> Option<Rect> {
if !self.ready {
return None;
}
let base = self.rest.height();
let min = self.lead.position.min(self.trail.position);
let max = self.lead.position.max(self.trail.position);
let y0 = min - base / 2.0;
Some(Rect::new(
self.rest.x0,
y0,
self.rest.x1,
y0 + (max - min) + base,
))
}
fn radius(&self) -> f64 {
self.rest.width().min(self.rest.height()) / 2.0
}
}
type TypeToken = (f32, f32, f32, FontWeight);
const SHAPING_INK: Color = Color::BLACK;
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
}
struct Run {
content: String,
layout: Option<TextLayout>,
shaped_for: Option<(TextStyle, Option<f64>)>,
}
impl Run {
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 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);
}
}
}
fn foreground(theme: Option<&Theme>, selected: bool) -> Color {
match (theme, selected) {
(Some(theme), true) => theme.scheme().on_secondary_container,
(Some(theme), false) => theme.scheme().on_surface_variant,
(None, true) => ON_SECONDARY_CONTAINER,
(None, false) => ON_SURFACE_VARIANT,
}
}
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 DrawerDestination<State: 'static> {
icon: AnyView<State>,
selected_icon: Option<AnyView<State>>,
label: String,
badge_label: Option<String>,
show_badge: bool,
semantic_label: Option<String>,
}
pub fn drawer_destination<State: 'static>(
icon: impl View<State>,
label: impl Into<String>,
) -> DrawerDestination<State> {
DrawerDestination {
icon: AnyView::new(icon),
selected_icon: None,
label: label.into(),
badge_label: None,
show_badge: false,
semantic_label: None,
}
}
impl<State: 'static> DrawerDestination<State> {
pub fn selected_icon(mut self, icon: impl View<State>) -> Self {
self.selected_icon = Some(AnyView::new(icon));
self
}
pub fn badge_label(mut self, label: impl Into<String>) -> Self {
self.badge_label = Some(label.into());
self
}
pub fn show_badge(mut self, show: bool) -> Self {
self.show_badge = show;
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
}
}
fn badge_text(&self) -> Option<&str> {
self.badge_label.as_deref()
}
}
pub struct DrawerSection<State: 'static> {
header: Option<String>,
destinations: Vec<DrawerDestination<State>>,
}
pub fn drawer_section<State: 'static>(
destinations: Vec<DrawerDestination<State>>,
) -> DrawerSection<State> {
DrawerSection {
header: None,
destinations,
}
}
impl<State: 'static> DrawerSection<State> {
pub fn header(mut self, header: impl Into<String>) -> Self {
self.header = Some(header.into());
self
}
}
type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
fn row_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 DrawerRowWidget {
icon: ChildPod,
label: Run,
label_text: String,
label_origin: Point,
badge: Run,
badge_dot: bool,
badge_size: Size,
badge_origin: Point,
selected: bool,
semantic_label: Option<String>,
on_select: ErasedCallback,
captured: bool,
}
fn build_row<State: 'static>(
destination: &DrawerDestination<State>,
selected: bool,
on_select: &OnSelect<State>,
index: usize,
ctx: &mut BuildCtx<'_>,
) -> ChildPod {
let widget = DrawerRowWidget {
icon: build_child(destination.active_icon(selected), ctx),
label: Run::new(destination.label.clone()),
label_text: destination.label.clone(),
label_origin: Point::ZERO,
badge: Run::new(destination.badge_text().unwrap_or("").to_string()),
badge_dot: destination.badge_label.is_none() && destination.show_badge,
badge_size: Size::ZERO,
badge_origin: Point::ZERO,
selected,
semantic_label: destination.semantic_label.clone(),
on_select: row_on_select::<State>(on_select, index),
captured: false,
};
ChildPod::new(Box::new(widget))
}
fn cancel_row(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);
}
impl Widget for DrawerRowWidget {
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 {
DESTINATION_HEIGHT
};
let theme = Theme::from_layout_ctx(ctx);
let style = type_style(theme.map(|t| &t.type_scale.label_large), LABEL_LARGE);
self.badge_size = if self.badge_dot {
Size::new(BADGE_DOT, BADGE_DOT)
} else if !self.badge.content.is_empty() {
self.badge.shape(ctx, &style, None)
} else {
Size::ZERO
};
let inner_w = (width - 2.0 * DESTINATION_INNER_PAD_H).max(0.0);
let badge_slot = if self.badge_size.width > 0.0 {
self.badge_size.width + ICON_LABEL_GAP
} else {
0.0
};
let label_max_w = (inner_w - ICON_SIZE - ICON_LABEL_GAP - badge_slot).max(0.0);
let label_size = self.label.shape(ctx, &style, Some(label_max_w));
let icon_size = self
.icon
.layout_child(ctx, &BoxConstraints::loose(Size::new(ICON_SIZE, ICON_SIZE)));
self.icon.set_origin(Point::new(
DESTINATION_INNER_PAD_H,
((height - icon_size.height) / 2.0).max(0.0),
));
self.label_origin = Point::new(
DESTINATION_INNER_PAD_H + ICON_SIZE + ICON_LABEL_GAP,
((height - label_size.height) / 2.0).max(0.0),
);
self.badge_origin = Point::new(
(width - DESTINATION_INNER_PAD_H - self.badge_size.width).max(0.0),
((height - self.badge_size.height) / 2.0).max(0.0),
);
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 ink = foreground(theme, self.selected);
let origin = ctx.origin();
self.icon.paint_child(ctx, scene);
self.label
.paint(origin + self.label_origin.to_vec2(), ink, scene);
if self.badge_dot {
scene.fill_rounded_rect(
origin + self.badge_origin.to_vec2(),
self.badge_size,
self.badge_size.height / 2.0,
ink,
);
} else if !self.badge.content.is_empty() {
self.badge
.paint(origin + self.badge_origin.to_vec2(), ink, 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| {
let label = self.semantic_label.as_deref().unwrap_or(&self.label_text);
node.set_label(label);
node.set_selected(self.selected);
});
}
visit_children!(icon);
}
struct SectionLayout {
first: usize,
count: usize,
header: Option<Run>,
header_origin: Point,
divider: bool,
divider_origin: Point,
}
pub struct DrawerContentWidget {
rows: Vec<ChildPod>,
sections: Vec<SectionLayout>,
selected: usize,
indicator: DrawerIndicator,
selection_moved: bool,
}
impl DrawerContentWidget {
fn selected_row_rect(&mut self) -> Option<Rect> {
let pod = self.rows.get_mut(self.selected)?;
Some(Rect::from_origin_size(pod.origin(), pod.size()))
}
fn natural_height(&self) -> f64 {
let mut h = 0.0;
for section in &self.sections {
if section.divider {
h += DIVIDER_THICKNESS;
}
if section.header.is_some() {
h += HEADER_ROW_HEIGHT;
}
h += section.count as f64 * ROW_OUTER_HEIGHT;
}
h
}
}
pub struct DrawerContentView<State: 'static> {
sections: Vec<DrawerSection<State>>,
selected: usize,
on_select: OnSelect<State>,
}
pub fn navigation_drawer_content<State: 'static, F: Fn(&mut State, usize) + 'static>(
sections: Vec<DrawerSection<State>>,
selected: usize,
on_select: F,
) -> DrawerContentView<State> {
DrawerContentView {
sections,
selected,
on_select: Rc::new(on_select),
}
}
impl<State: 'static> DrawerContentView<State> {
fn destinations(&self) -> Vec<&DrawerDestination<State>> {
self.sections
.iter()
.flat_map(|section| section.destinations.iter())
.collect()
}
}
impl<State: 'static> View<State> for DrawerContentView<State> {
type Element = DrawerContentWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> DrawerContentWidget {
let mut rows = Vec::new();
let mut sections = Vec::new();
for section in &self.sections {
sections.push(SectionLayout {
first: rows.len(),
count: section.destinations.len(),
header: section.header.as_ref().map(Run::new),
header_origin: Point::ZERO,
divider: !sections.is_empty(),
divider_origin: Point::ZERO,
});
for destination in §ion.destinations {
let index = rows.len();
rows.push(build_row(
destination,
index == self.selected,
&self.on_select,
index,
ctx,
));
}
}
DrawerContentWidget {
rows,
sections,
selected: self.selected,
indicator: DrawerIndicator::new(),
selection_moved: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut DrawerContentWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
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.rows[index];
let widget = pod
.widget_mut()
.downcast_mut::<DrawerRowWidget>()
.expect("a drawer row pod holds a DrawerRowWidget");
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;
}
widget.label_text = next_dests[index].label.clone();
let next_badge = next_dests[index].badge_text().unwrap_or("");
if widget.badge.set_content(next_badge) {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
let next_dot = next_dests[index].badge_label.is_none() && next_dests[index].show_badge;
if widget.badge_dot != next_dot {
widget.badge_dot = next_dot;
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;
}
widget.on_select = row_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.rows.push(build_row(
destination,
index == self.selected,
&self.on_select,
index,
ctx,
));
}
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.rows[index];
if pod.is_active() {
cancel_row(pod);
pod.set_active(false);
}
let widget = pod
.widget_mut()
.downcast_mut::<DrawerRowWidget>()
.expect("a drawer row pod holds a DrawerRowWidget");
teardown_child(
destination.active_icon(index == prev.selected),
&mut widget.icon,
ctx,
);
}
element.rows.truncate(common);
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()
.enumerate()
.map(|(i, section)| {
let layout = SectionLayout {
first,
count: section.destinations.len(),
header: section.header.as_ref().map(Run::new),
header_origin: Point::ZERO,
divider: i > 0,
divider_origin: Point::ZERO,
};
first += section.destinations.len();
layout
})
.collect();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.selected != self.selected {
element.selected = self.selected;
element.selection_moved = true;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags
}
fn teardown(&self, element: &mut DrawerContentWidget, ctx: &mut BuildCtx<'_>) {
for (index, destination) in self.destinations().iter().enumerate() {
let Some(pod) = element.rows.get_mut(index) else {
continue;
};
let widget = pod
.widget_mut()
.downcast_mut::<DrawerRowWidget>()
.expect("a drawer row pod holds a DrawerRowWidget");
teardown_child(
destination.active_icon(index == self.selected),
&mut widget.icon,
ctx,
);
}
}
}
impl Widget for DrawerContentWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
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) && !reduce_motion;
let height = if bc.max().height.is_finite() {
bc.max().height
} else {
self.natural_height()
};
let size = bc.constrain(Size::new(DRAWER_WIDTH, height));
let width = size.width;
let mut sections = std::mem::take(&mut self.sections);
let mut y = 0.0;
for section in &mut sections {
if section.divider {
section.divider_origin = Point::new(0.0, y);
y += DIVIDER_THICKNESS;
}
if 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);
let max_w = (width - 2.0 * HEADLINE_PAD_H).max(0.0);
let header_size = header.shape(ctx, &style, Some(max_w));
section.header_origin = Point::new(HEADLINE_PAD_H, y + HEADLINE_PAD_V);
y += header_size.height + 2.0 * HEADLINE_PAD_V;
}
let row_w = (width - 2.0 * DESTINATION_PAD_H).max(0.0);
for index in section.first..section.first + section.count {
let Some(pod) = self.rows.get_mut(index) else {
continue;
};
pod.layout_child(
ctx,
&BoxConstraints::tight(Size::new(row_w, DESTINATION_HEIGHT)),
);
pod.set_origin(Point::new(DESTINATION_PAD_H, y + DESTINATION_PAD_V));
y += ROW_OUTER_HEIGHT;
}
}
self.sections = sections;
if let Some(rect) = self.selected_row_rect() {
self.indicator.sync(rect, travel);
}
size
}
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 header_ink = theme.map_or(ON_SURFACE_VARIANT, |t| t.scheme().on_surface_variant);
let divider_ink = theme.map_or(OUTLINE_VARIANT, |t| t.scheme().outline_variant);
let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
let origin = ctx.origin();
let width = ctx.size().width;
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(
origin + Vec2::new(pill.x0, pill.y0),
pill.size(),
self.indicator.radius(),
indicator_fill,
);
}
for section in &self.sections {
if section.divider {
scene.fill_rect(
origin + Vec2::new(0.0, section.divider_origin.y),
Size::new(width, DIVIDER_THICKNESS),
divider_ink,
);
}
if let Some(header) = §ion.header {
header.paint(origin + section.header_origin.to_vec2(), header_ink, scene);
}
}
for pod in &mut self.rows {
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
route_event(&mut self.rows, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::TabList,
|_| {},
|ctx| {
for pod in &self.rows {
pod.semantics_child(ctx);
}
},
);
}
visit_children!(rows);
}
fn drawer_config() -> OverlayModalConfig {
OverlayModalConfig::edge(OverlaySide::Left)
.extent(OverlayExtent::Content, OverlayLimit::Px(DRAWER_WIDTH))
}
pub struct NavigationDrawerView<State: 'static>(OverlayModalView<State>);
pub fn navigation_drawer<State: 'static, F: Fn(&mut State, usize) + 'static>(
sections: Vec<DrawerSection<State>>,
selected: usize,
on_select: F,
) -> NavigationDrawerView<State> {
let content = navigation_drawer_content(sections, selected, on_select);
NavigationDrawerView(overlay_modal(content, drawer_config()).label("Navigation drawer"))
}
#[allow(non_snake_case)]
pub fn NavigationDrawer<State: 'static, F: Fn(&mut State, usize) + 'static>(
sections: Vec<DrawerSection<State>>,
selected: usize,
on_select: F,
) -> NavigationDrawerView<State> {
navigation_drawer(sections, selected, on_select)
}
impl<State: 'static> NavigationDrawerView<State> {
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.0 = self.0.dismissable(dismissable);
self
}
pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
self.0 = self.0.on_dismiss(on_dismiss);
self
}
}
impl<State: 'static> View<State> for NavigationDrawerView<State> {
type Element = OverlayModalWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
View::build(&self.0, ctx)
}
fn rebuild(
&self,
prev: &Self,
element: &mut OverlayModalWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
View::rebuild(&self.0, &prev.0, element, ctx)
}
fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
View::teardown(&self.0, element, ctx);
}
}
impl<State: 'static> OverlayModalContent<State> for NavigationDrawerView<State> {
fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
self.0 = self.0.on_modal_dismiss(on_dismiss);
self
}
fn modal_dismissable(&self) -> bool {
self.0.modal_dismissable()
}
}
pub fn show_navigation_drawer<State, B, R>(
controller: &NavigatorController<State>,
build: B,
on_result: R,
) where
State: 'static,
B: Fn() -> NavigationDrawerView<State> + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
show_overlay_modal(controller, build, on_result)
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::text::TextContext;
use frust::authoring::{
BuildCtx, CornerRadii, EventCtx, PointerButton, PointerEvent, PointerPhase, Role, Widget,
any,
};
use frust::{FrameTime, NavigatorView, icon};
use frust_core::RenderRoot;
use frust_widgets::navigator;
use kurbo::{Point, Rect, Size};
use peniko::Color;
use std::any::Any;
const WINDOW: Size = Size::new(400.0, 600.0);
fn sample_destination<State: 'static>(label: &str) -> DrawerDestination<State> {
drawer_destination(icon(crate::icons::HOME), label)
}
fn one_section<State: 'static>(labels: &[&str]) -> Vec<DrawerSection<State>> {
vec![drawer_section(
labels.iter().map(|l| sample_destination(l)).collect(),
)]
}
fn build<S: 'static>(view: &DrawerContentView<S>) -> DrawerContentWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut DrawerContentWidget, bc: &BoxConstraints) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, bc)
}
fn ft(ms: u64) -> FrameTime {
FrameTime::from_nanos(ms * 1_000_000)
}
#[test]
fn drawer_config_pins_a_left_edge_fixed_width_panel_with_end_corners_only() {
let config = drawer_config();
assert_eq!(
config.geometry,
crate::overlay::OverlayGeometry::Edge {
side: OverlaySide::Left,
extent: OverlayExtent::Content,
limit: OverlayLimit::Px(DRAWER_WIDTH),
}
);
assert_eq!(
config.corners,
crate::overlay::OverlayCorners::End,
"a left-pinned panel rounds its right (screen-facing) corners only"
);
assert_eq!(
config.entrance,
crate::overlay::OverlayEntrance::Slide,
"a drawer slides in from its own edge"
);
assert!(!config.close_button, "upstream paints no close affordance");
assert!(
!config.drag,
"no edge-swipe-to-open exists upstream to port"
);
}
#[test]
fn unthemed_modal_paint_rounds_only_the_right_corners() {
let view: NavigationDrawerView<()> = {
let content = navigation_drawer_content(one_section(&["Home"]), 0, |_: &mut (), _| {});
NavigationDrawerView(
overlay_modal(
content,
drawer_config().entrance(crate::overlay::OverlayEntrance::None),
)
.label("Navigation drawer"),
)
};
let mut counter = 0u64;
let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(WINDOW));
#[derive(Default)]
struct Recorder {
panels: Vec<(Point, Size, CornerRadii, Color)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect_radii(
&mut self,
origin: Point,
size: Size,
radii: CornerRadii,
color: Color,
) {
self.panels.push((origin, size, radii, color));
}
fn push_clip_rounded_radii(&mut self, _o: Point, _s: Size, _r: CornerRadii) {}
fn push_clip(&mut self, _o: Point, _s: Size) {}
fn pop_clip(&mut self) {}
}
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, WINDOW);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.panels.len(), 1);
let (origin, size, radii, _) = rec.panels[0];
assert_eq!(origin.x, 0.0, "pinned to the leading (left) edge");
assert_eq!(size.width, DRAWER_WIDTH);
assert_eq!(radii.top_left, 0.0, "left corners are square (screen edge)");
assert_eq!(
radii.bottom_left, 0.0,
"left corners are square (screen edge)"
);
assert!(radii.top_right > 0.0, "right corners round");
assert!(radii.bottom_right > 0.0, "right corners round");
}
#[test]
fn the_standalone_content_self_sizes_to_drawer_width_under_a_loose_or_wide_bounded_offer() {
let view: DrawerContentView<()> =
navigation_drawer_content(one_section(&["Home", "Search"]), 0, |_: &mut (), _| {});
let mut w = build(&view);
let loose = layout(
&mut w,
&BoxConstraints::loose(Size::new(f64::INFINITY, 600.0)),
);
assert_eq!(loose.width, DRAWER_WIDTH);
let mut w2 = build(&view);
let wide = layout(&mut w2, &BoxConstraints::loose(Size::new(900.0, 600.0)));
assert_eq!(
wide.width, DRAWER_WIDTH,
"a fixed-width panel does not stretch to fill a wider offer"
);
}
#[test]
fn the_standalone_content_clamps_down_when_the_offer_is_narrower_than_drawer_width() {
let view: DrawerContentView<()> =
navigation_drawer_content(one_section(&["Home"]), 0, |_: &mut (), _| {});
let mut w = build(&view);
let size = layout(&mut w, &BoxConstraints::tight(Size::new(250.0, 600.0)));
assert_eq!(size.width, 250.0);
}
#[test]
fn a_single_section_paints_no_divider_and_positions_rows_top_to_bottom() {
let view: DrawerContentView<()> = navigation_drawer_content(
one_section(&["Home", "Search", "Agenda"]),
0,
|_: &mut (), _| {},
);
let mut w = build(&view);
layout(&mut w, &BoxConstraints::tight(WINDOW));
assert!(!w.sections[0].divider);
assert_eq!(w.rows[0].origin().y, DESTINATION_PAD_V);
assert_eq!(w.rows[1].origin().y, DESTINATION_PAD_V + ROW_OUTER_HEIGHT);
assert_eq!(
w.rows[2].origin().y,
DESTINATION_PAD_V + 2.0 * ROW_OUTER_HEIGHT
);
}
#[test]
fn a_second_section_paints_a_divider_and_an_optional_header_above_its_own_rows() {
let sections: Vec<DrawerSection<()>> = vec![
drawer_section(vec![sample_destination("Home")]),
drawer_section(vec![sample_destination("Settings")]).header("More"),
];
let view: DrawerContentView<()> =
navigation_drawer_content(sections, 0, |_: &mut (), _| {});
let mut w = build(&view);
layout(&mut w, &BoxConstraints::tight(WINDOW));
assert!(
!w.sections[0].divider,
"the first section never gets a divider"
);
assert!(w.sections[1].divider, "every later section does");
assert!(w.sections[1].header.is_some());
let expected_second_row_y =
ROW_OUTER_HEIGHT + DIVIDER_THICKNESS + HEADER_ROW_HEIGHT + DESTINATION_PAD_V;
assert_eq!(w.rows[1].origin().y, expected_second_row_y);
}
fn dispatch<S: 'static>(
w: &mut DrawerContentWidget,
state: &mut S,
event: &InputEvent,
) -> EventResult {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
w.event(&mut ctx, event)
}
fn press_at(phase: PointerPhase, position: Point) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position,
button: PointerButton::Primary,
})
}
#[test]
fn pressing_a_row_fires_on_select_with_its_flat_index_on_up_inside_only() {
#[derive(Default)]
struct S {
selected: Option<usize>,
}
let view: DrawerContentView<S> = navigation_drawer_content(
vec![drawer_section(vec![
sample_destination("Home"),
sample_destination("Search"),
])],
0,
|s: &mut S, i| s.selected = Some(i),
);
let mut counter = 0u64;
let mut w = View::<S>::build(&view, &mut BuildCtx::new(&mut counter));
layout(&mut w, &BoxConstraints::tight(WINDOW));
let rect = Rect::from_origin_size(w.rows[1].origin(), w.rows[1].size());
let center = rect.center();
let mut state = S::default();
dispatch(&mut w, &mut state, &press_at(PointerPhase::Down, center));
assert_eq!(state.selected, None, "no fire on Down");
dispatch(&mut w, &mut state, &press_at(PointerPhase::Up, center));
assert_eq!(
state.selected,
Some(1),
"fires with the pressed row's index"
);
}
#[test]
fn selecting_a_new_row_travels_the_indicator_to_its_settled_rect() {
let view0: DrawerContentView<()> =
navigation_drawer_content(one_section(&["Home", "Search"]), 0, |_: &mut (), _| {});
let view1: DrawerContentView<()> =
navigation_drawer_content(one_section(&["Home", "Search"]), 1, |_: &mut (), _| {});
let mut counter = 0u64;
let mut w = View::<()>::build(&view0, &mut BuildCtx::new(&mut counter));
layout(&mut w, &BoxConstraints::tight(WINDOW));
let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, ft(0));
let mut scene = frust_widgets::test_support::RecordingScene::default();
w.paint(&mut pctx, &mut scene);
View::<()>::rebuild(&view1, &view0, &mut w, &mut BuildCtx::new(&mut counter));
layout(&mut w, &BoxConstraints::tight(WINDOW));
let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, ft(50));
w.paint(&mut pctx, &mut scene);
layout(&mut w, &BoxConstraints::tight(WINDOW));
let mut pctx2 = PaintCtx::for_test(Point::ZERO, WINDOW, ft(2000));
w.paint(&mut pctx2, &mut scene);
let expected = Rect::from_origin_size(w.rows[1].origin(), w.rows[1].size());
let pill = w.indicator.pill_rect().expect("indicator is ready");
assert!((pill.y0 - expected.y0).abs() < 0.5);
assert!((pill.y1 - expected.y1).abs() < 0.5);
}
#[test]
fn an_interleaved_non_travel_sync_at_the_same_geometry_does_not_abort_an_in_flight_travel() {
let mut indicator = DrawerIndicator::new();
let rest0 = Rect::from_origin_size(Point::new(0.0, 0.0), Size::new(300.0, 56.0));
indicator.sync(rest0, false);
assert!(!indicator.is_animating(), "first layout jumps, not travels");
let rest1 = Rect::from_origin_size(Point::new(0.0, 56.0), Size::new(300.0, 56.0));
indicator.sync(rest1, true);
assert!(
indicator.is_animating(),
"a selection change starts an in-flight travel"
);
indicator.advance(ft(50));
assert!(
indicator.is_animating(),
"still travelling after a partial advance"
);
indicator.sync(rest1, false);
assert!(
indicator.is_animating(),
"an interleaved no-op layout at the same geometry must not abort \
an in-flight travel"
);
indicator.advance(ft(2000));
assert!(!indicator.is_animating(), "travel completes normally");
let pill = indicator.pill_rect().expect("indicator is ready");
assert!((pill.center().y - rest1.center().y).abs() < 0.5);
}
#[test]
fn a_non_travel_sync_at_a_genuinely_changed_geometry_still_jumps() {
let mut indicator = DrawerIndicator::new();
let rest0 = Rect::from_origin_size(Point::new(0.0, 0.0), Size::new(300.0, 56.0));
indicator.sync(rest0, false);
let rest1 = Rect::from_origin_size(Point::new(0.0, 56.0), Size::new(300.0, 56.0));
indicator.sync(rest1, true);
indicator.advance(ft(50));
assert!(indicator.is_animating());
let rest2 = Rect::from_origin_size(Point::new(0.0, 200.0), Size::new(300.0, 56.0));
indicator.sync(rest2, false);
assert!(
!indicator.is_animating(),
"a genuine geometry change jumps rather than travelling"
);
let pill = indicator.pill_rect().expect("indicator is ready");
assert!((pill.center().y - rest2.center().y).abs() < 0.5);
}
fn badge_dot_would_paint<State: 'static>(d: &DrawerDestination<State>) -> bool {
d.badge_label.is_none() && d.show_badge
}
#[test]
fn a_badge_label_paints_trailing_text_and_a_dot_badge_paints_when_no_label_is_set() {
let with_text: DrawerDestination<()> =
drawer_destination(icon(crate::icons::HOME), "Search").badge_label("3");
assert!(!badge_dot_would_paint(&with_text));
let with_dot: DrawerDestination<()> =
drawer_destination(icon(crate::icons::HOME), "Search").show_badge(true);
assert!(badge_dot_would_paint(&with_dot));
let both: DrawerDestination<()> = drawer_destination(icon(crate::icons::HOME), "Search")
.badge_label("3")
.show_badge(true);
assert!(
!badge_dot_would_paint(&both),
"a badge_label wins over show_badge"
);
}
#[test]
fn semantics_is_a_tab_list_with_the_selected_row_flagged() {
fn logic(_s: &mut ()) -> DrawerContentView<()> {
navigation_drawer_content(one_section(&["Home", "Search"]), 1, |_: &mut (), _| {})
}
let mut root: RenderRoot<(), DrawerContentView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
let update = root.semantics();
assert!(update.nodes.iter().any(|(_, n)| n.role() == Role::TabList));
let tabs: Vec<_> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::Tab)
.collect();
assert_eq!(tabs.len(), 2);
assert!(tabs.iter().any(|(_, n)| n.label() == Some("Home")));
let selected = tabs
.iter()
.find(|(_, n)| n.label() == Some("Search"))
.expect("the second destination carries its own label");
assert_eq!(selected.1.is_selected(), Some(true));
}
#[derive(Default)]
struct NavState {
results: Vec<Option<i32>>,
}
struct BgPage {
size: Size,
}
struct BgPageW {
size: Size,
}
impl View<NavState> for BgPage {
type Element = BgPageW;
fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageW {
BgPageW { size: self.size }
}
fn rebuild(&self, _p: &Self, _e: &mut BgPageW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for BgPageW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
}
fn bg_page(w: f64, h: f64) -> BgPage {
BgPage {
size: Size::new(w, h),
}
}
#[test]
fn show_navigation_drawer_stages_a_scrim_tap_dismiss_through_the_navigator() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = {
let ctrl = controller.clone();
move |_: &mut NavState| {
navigator(&ctrl, || {
any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
})
}
};
let mut state = NavState::default();
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
show_navigation_drawer(
&controller,
|| navigation_drawer(one_section(&["Home"]), 0, |_: &mut NavState, _| {}),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<i32>());
},
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
for t in [0u64, 100, 200, 300, 400, 550] {
root.paint(
&mut frust_widgets::test_support::RecordingScene::default(),
ft(t),
);
}
root.event(
&mut state,
&InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: Point::new(WINDOW.width - 5.0, 5.0),
button: PointerButton::Primary,
}),
);
root.event(
&mut state,
&InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Up,
position: Point::new(WINDOW.width - 5.0, 5.0),
button: PointerButton::Primary,
}),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
let mut frames = 0u64;
loop {
root.rebuild(&mut app, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
let outcome = root.paint(
&mut frust_widgets::test_support::RecordingScene::default(),
ft(600 + frames * 100),
);
frames += 1;
assert!(
frames < 30,
"the staged exit settles within a bounded number of frames"
);
if !outcome.needs_frame {
break;
}
}
root.event(
&mut state,
&InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Move,
position: Point::new(WINDOW.width - 5.0, 5.0),
button: PointerButton::Primary,
}),
);
assert_eq!(
state.results,
vec![None],
"a scrim tap stages the exit and pops with an empty result"
);
}
}