use std::rc::Rc;
use frust::authoring::text::{FontWeight, LineHeight};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, ThemeTextType, View, Widget, any,
};
use frust::text;
use kurbo::{Point, Size};
use crate::overlay::{
ModalDismiss, OverlayExtent, OverlayLimit, OverlayModalConfig, OverlayModalContent,
OverlayModalView, OverlayModalWidget, OverlaySide, overlay_modal, show_overlay_modal,
};
use crate::tokens::MaterialSpacing;
use frust::{NavigatorController, PopResult};
#[allow(dead_code)]
const SIDE_SHEET_MIN_WIDTH: f64 = 256.0;
pub const SIDE_SHEET_WIDTH: f64 = 320.0;
const HEADER_PAD_LEFT: f64 = MaterialSpacing::XL;
const HEADER_PAD_TOP: f64 = MaterialSpacing::LG;
const HEADER_PAD_BOTTOM: f64 = MaterialSpacing::LG;
const HEADER_PAD_RIGHT: f64 = MaterialSpacing::SM;
const CLOSE_TAP_TARGET: f64 = 2.0 * MaterialSpacing::MD + 24.0;
const HEADER_TRAILING_RESERVE: f64 = HEADER_PAD_RIGHT + CLOSE_TAP_TARGET;
const HEADER_HEIGHT: f64 = HEADER_PAD_TOP + CLOSE_TAP_TARGET + HEADER_PAD_BOTTOM;
const ACTIONS_PADDING: f64 = MaterialSpacing::LG;
const TITLE_SIZE: f32 = 22.0;
const TITLE_LINE_HEIGHT: f32 = 28.0;
const TITLE_WEIGHT: FontWeight = FontWeight::REGULAR;
fn side_sheet_config() -> OverlayModalConfig {
OverlayModalConfig::edge(OverlaySide::Right)
.extent(OverlayExtent::Content, OverlayLimit::Px(SIDE_SHEET_WIDTH))
.close_button(true)
}
fn title_view<State: 'static>(title: &str) -> AnyView<State> {
any::<State, _>(
text(title.to_string())
.size(TITLE_SIZE)
.weight(TITLE_WEIGHT)
.line_height(LineHeight::Absolute(TITLE_LINE_HEIGHT))
.themed_family(ThemeTextType::TitleLarge),
)
}
struct SideSheetContentView<State: 'static> {
title: String,
body: AnyView<State>,
actions: Vec<AnyView<State>>,
}
impl<State: 'static> View<State> for SideSheetContentView<State> {
type Element = SideSheetContentWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> SideSheetContentWidget {
SideSheetContentWidget {
title: frust::authoring::build_child(&title_view::<State>(&self.title), ctx),
body: frust::authoring::build_child(&self.body, ctx),
divider: (!self.actions.is_empty()).then(|| {
frust::authoring::build_child(&any::<State, _>(crate::divider::divider()), ctx)
}),
actions: self
.actions
.iter()
.map(|v| frust::authoring::build_child(v, ctx))
.collect(),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut SideSheetContentWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = frust::authoring::rebuild_child(
&title_view::<State>(&prev.title),
&title_view::<State>(&self.title),
&mut element.title,
ctx,
);
flags |= frust::authoring::rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
match (prev.actions.is_empty(), self.actions.is_empty()) {
(true, false) => {
element.divider = Some(frust::authoring::build_child(
&any::<State, _>(crate::divider::divider()),
ctx,
));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(false, true) => {
if let Some(pod) = element.divider.as_mut() {
frust::authoring::teardown_child(
&any::<State, _>(crate::divider::divider()),
pod,
ctx,
);
}
element.divider = None;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
_ => {}
}
let prev_views: Vec<&AnyView<State>> = prev.actions.iter().collect();
let next_views: Vec<&AnyView<State>> = self.actions.iter().collect();
flags |= frust::authoring::rebuild_children(
&prev_views,
&next_views,
&mut element.actions,
ctx,
|view: &&AnyView<State>| *view,
|_| None,
);
flags
}
fn teardown(&self, element: &mut SideSheetContentWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(
&title_view::<State>(&self.title),
&mut element.title,
ctx,
);
frust::authoring::teardown_child(&self.body, &mut element.body, ctx);
if let Some(pod) = element.divider.as_mut() {
frust::authoring::teardown_child(&any::<State, _>(crate::divider::divider()), pod, ctx);
}
for (view, pod) in self.actions.iter().zip(element.actions.iter_mut()) {
frust::authoring::teardown_child(view, pod, ctx);
}
}
}
struct SideSheetContentWidget {
title: ChildPod,
body: ChildPod,
divider: Option<ChildPod>,
actions: Vec<ChildPod>,
}
impl Widget for SideSheetContentWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = crate::overlay::finite_or_zero(bc.max().width);
let height = crate::overlay::finite_or_zero(bc.max().height);
let title_max_w = (width - HEADER_PAD_LEFT - HEADER_TRAILING_RESERVE).max(0.0);
let title_size = self.title.layout_child(
ctx,
&BoxConstraints::loose(Size::new(title_max_w, f64::INFINITY)),
);
self.title.set_origin(Point::new(
HEADER_PAD_LEFT,
((HEADER_HEIGHT - title_size.height) / 2.0).max(0.0),
));
let mut footer_h = 0.0;
if let Some(divider) = self.divider.as_mut() {
let divider_size =
divider.layout_child(ctx, &BoxConstraints::loose(Size::new(width, height)));
let action_bc = BoxConstraints::loose(Size::new(
(width - 2.0 * ACTIONS_PADDING).max(0.0),
f64::INFINITY,
));
let mut actions_h: f64 = 0.0;
for pod in self.actions.iter_mut() {
let s = pod.layout_child(ctx, &action_bc);
actions_h = actions_h.max(s.height);
}
footer_h = divider_size.height + 2.0 * ACTIONS_PADDING + actions_h;
let footer_y = height - footer_h;
divider.set_origin(Point::new(0.0, footer_y));
let mut ax = ACTIONS_PADDING;
let actions_y = footer_y + divider_size.height + ACTIONS_PADDING;
for pod in self.actions.iter_mut() {
let s = pod.size();
pod.set_origin(Point::new(ax, actions_y));
ax += s.width;
}
}
let body_h = (height - HEADER_HEIGHT - footer_h).max(0.0);
self.body
.layout_child(ctx, &BoxConstraints::tight(Size::new(width, body_h)));
self.body.set_origin(Point::new(0.0, HEADER_HEIGHT));
Size::new(width, height)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.title.paint_child(ctx, scene);
self.body.paint_child(ctx, scene);
if let Some(divider) = self.divider.as_mut() {
divider.paint_child(ctx, scene);
}
for pod in &mut self.actions {
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
self.body.event_child(ctx, event);
for pod in &mut self.actions {
pod.event_child(ctx, event);
}
return EventResult::Ignored;
}
if frust::authoring::route_event_single(&mut self.body, ctx, event) == EventResult::Handled
{
return EventResult::Handled;
}
frust::authoring::route_event(&mut self.actions, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.title.semantics_child(ctx);
self.body.semantics_child(ctx);
if let Some(divider) = &self.divider {
divider.semantics_child(ctx);
}
for pod in &self.actions {
pod.semantics_child(ctx);
}
}
frust::authoring::visit_children!(title, body, divider, actions);
}
pub struct SideSheetView<State: 'static>(OverlayModalView<State>);
pub fn side_sheet<State: 'static, V: View<State>>(
title: impl Into<String>,
body: V,
actions: Vec<AnyView<State>>,
) -> SideSheetView<State> {
let title = title.into();
let content = SideSheetContentView {
title: title.clone(),
body: any(body),
actions,
};
SideSheetView(overlay_modal(content, side_sheet_config()).label(title))
}
impl<State: 'static> SideSheetView<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
}
pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
self.0 = self.0.dismiss_handle(dismiss);
self
}
}
impl<State: 'static> View<State> for SideSheetView<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 SideSheetView<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_side_sheet<State, B, R>(controller: &NavigatorController<State>, build: B, on_result: R)
where
State: 'static,
B: Fn() -> SideSheetView<State> + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
show_overlay_modal(controller, build, on_result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::overlay::{OverlayCorners, OverlayEntrance, OverlayGeometry};
use frust::FrameTime;
use frust::NavigatorView;
use frust::authoring::text::TextContext;
use frust::authoring::{
BuildCtx, ChangeFlags, CornerRadii, EventCtx, PointerButton, PointerEvent, PointerPhase,
Role, Widget, any as core_any,
};
use frust_core::RenderRoot;
use frust_widgets::navigator;
use frust_widgets::test_support::leaf_any;
use kurbo::Rect;
use peniko::Color;
use std::any::Any;
const WINDOW: Size = Size::new(400.0, 600.0);
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
panels: Vec<(Point, Size, CornerRadii, Color)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
self.rects.push((origin, size, color));
}
fn draw_text(&mut self, _origin: Point, _text: &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, _origin: Point, _size: Size, _radii: CornerRadii) {}
fn push_clip(&mut self, _origin: Point, _size: Size) {}
fn pop_clip(&mut self) {}
}
fn settled_view<State: 'static, V: View<State>>(
title: &str,
body: V,
actions: Vec<AnyView<State>>,
) -> SideSheetView<State> {
let content = SideSheetContentView {
title: title.to_string(),
body: any(body),
actions,
};
SideSheetView(
overlay_modal(content, side_sheet_config().entrance(OverlayEntrance::None))
.label(title.to_string()),
)
}
fn build<S: 'static>(view: &SideSheetView<S>) -> OverlayModalWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut OverlayModalWidget) -> Size {
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))
}
#[test]
fn side_sheet_config_pins_a_right_edge_fixed_width_panel_with_left_corners_only() {
let config = side_sheet_config();
assert_eq!(
config.geometry,
OverlayGeometry::Edge {
side: OverlaySide::Right,
extent: OverlayExtent::Content,
limit: OverlayLimit::Px(SIDE_SHEET_WIDTH),
}
);
assert_eq!(
config.corners,
OverlayCorners::Start,
"a right-pinned panel rounds its left (screen-facing) corners only"
);
assert_eq!(
config.entrance,
OverlayEntrance::Slide,
"a side sheet slides in from its own edge"
);
assert!(
config.close_button,
"the host's own close affordance is enabled, not a second one"
);
}
#[test]
fn the_fixed_width_sits_inside_the_m3_side_sheet_spec_band() {
assert!(
(SIDE_SHEET_MIN_WIDTH..=crate::overlay::OVERLAY_SIDE_SHEET_MAX_WIDTH)
.contains(&SIDE_SHEET_WIDTH),
"320dp (the reference's own default) must sit inside the M3 [256, 400]dp band"
);
}
#[test]
fn layout_reserves_the_header_and_fills_the_body_with_no_footer() {
let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
let mut w = build(&view);
let size = layout(&mut w);
assert_eq!(size, WINDOW, "the host fills its own area (the scrim)");
let panel = w.panel_rect();
assert_eq!(
panel.width(),
SIDE_SHEET_WIDTH,
"a 400px window is wider than SIDE_SHEET_WIDTH, so no clamp bites"
);
assert_eq!(panel.height(), WINDOW.height, "full height");
assert_eq!(panel.x1, WINDOW.width);
}
#[test]
fn layout_adds_a_divider_and_a_left_packed_footer_row_when_actions_is_non_empty() {
let view: SideSheetView<()> = settled_view(
"Filters",
leaf_any(100.0, 100.0),
vec![leaf_any(60.0, 32.0), leaf_any(60.0, 32.0)],
);
let mut w = build(&view);
layout(&mut w);
let panel_h = w.panel_rect().height();
let no_actions: SideSheetView<()> =
settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
let mut w2 = build(&no_actions);
layout(&mut w2);
assert_eq!(
w.panel_rect().height(),
w2.panel_rect().height(),
"the panel itself is always full height regardless of a footer"
);
assert_eq!(panel_h, WINDOW.height);
}
fn paint(w: &mut OverlayModalWidget) -> Recorder {
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, WINDOW);
w.paint(&mut pctx, &mut rec);
rec
}
#[test]
fn unthemed_paint_rounds_only_the_left_corners_at_the_fallback_radius() {
let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
let mut w = build(&view);
layout(&mut w);
let rec = paint(&mut w);
assert_eq!(rec.panels.len(), 1, "one panel fill");
let (origin, size, radii, _color) = rec.panels[0];
assert_eq!(size.width, SIDE_SHEET_WIDTH);
assert_eq!(origin.x, WINDOW.width - SIDE_SHEET_WIDTH, "pinned trailing");
let radius = crate::overlay::radius(None);
assert_eq!(radii.top_left, radius, "left corners round");
assert_eq!(radii.bottom_left, radius, "left corners round");
assert_eq!(
radii.top_right, 0.0,
"right corners are square (screen edge)"
);
assert_eq!(
radii.bottom_right, 0.0,
"right corners are square (screen edge)"
);
}
#[test]
fn themed_paint_resolves_the_extra_large_shape_token_on_the_rounded_corners() {
let theme = crate::baseline();
let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
let mut w = build(&view);
layout(&mut w);
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
w.paint(&mut pctx, &mut rec);
let (_, _, radii, _) = rec.panels[0];
assert_eq!(radii.top_left, theme.shape.extra_large);
assert_eq!(radii.bottom_left, theme.shape.extra_large);
assert_eq!(radii.top_right, 0.0);
assert_eq!(radii.bottom_right, 0.0);
}
#[test]
fn the_real_constructor_starts_off_screen_and_slides_to_the_trailing_edge() {
let view: SideSheetView<()> = side_sheet("Filters", leaf_any(100.0, 100.0), Vec::new());
let mut w = build(&view);
layout(&mut w);
assert_eq!(
w.progress(),
0.0,
"an un-settled Slide entrance starts closed"
);
let mut rec = Recorder::default();
let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
w.paint(&mut pctx, &mut rec);
layout(&mut w);
let mut rec2 = Recorder::default();
let mut pctx2 =
PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(2_000_000_000));
w.paint(&mut pctx2, &mut rec2);
layout(&mut w);
assert_eq!(w.progress(), 1.0, "the slide settles fully open");
assert_eq!(
w.panel_rect().x1,
WINDOW.width,
"settled, the panel is flush against the trailing edge"
);
}
#[derive(Default)]
struct Flag {
dismissed: u32,
}
fn dispatch(w: &mut OverlayModalWidget, state: &mut Flag, 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 ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn settled_flag_view(actions: Vec<AnyView<Flag>>) -> SideSheetView<Flag> {
let mut view = settled_view("Filters", leaf_any_flag(100.0, 100.0), actions);
view = view.on_dismiss(|s: &mut Flag| s.dismissed += 1);
view
}
fn leaf_any_flag(w: f64, h: f64) -> AnyView<Flag> {
core_any::<Flag, _>(FixedLeaf {
size: Size::new(w, h),
})
}
struct FixedLeaf {
size: Size,
}
struct FixedLeafW {
size: Size,
}
impl View<Flag> for FixedLeaf {
type Element = FixedLeafW;
fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
FixedLeafW { size: self.size }
}
fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for FixedLeafW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
}
#[test]
fn the_hosts_close_affordance_dismisses_via_on_dismiss() {
let view = settled_flag_view(Vec::new());
let mut w = build(&view);
layout(&mut w);
let close: Rect = w.close_rect().expect("close_button(true) is configured");
let center = close.center();
let mut state = Flag::default();
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, center.x, center.y),
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, center.x, center.y),
);
assert_eq!(state.dismissed, 1);
}
#[test]
fn a_scrim_tap_outside_the_panel_dismisses() {
let view = settled_flag_view(Vec::new());
let mut w = build(&view);
layout(&mut w);
assert!(!w.panel_rect().contains(Point::new(5.0, 5.0)));
let mut state = Flag::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.dismissed, 1);
}
#[derive(Default)]
struct NavState {
results: Vec<Option<i32>>,
}
fn ft(ms: u64) -> FrameTime {
FrameTime::from_nanos(ms * 1_000_000)
}
#[test]
fn show_side_sheet_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, || {
core_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_side_sheet(
&controller,
|| side_sheet("Filters", bg_page(100.0, 100.0), Vec::new()),
|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 Recorder::default(), ft(t));
}
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
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 Recorder::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, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"a scrim tap stages the exit and pops with an empty result"
);
}
#[test]
fn back_request_stages_the_exit_and_pops_via_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, || {
core_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_side_sheet(
&controller,
|| side_sheet("Filters", bg_page(100.0, 100.0), Vec::new()),
|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 Recorder::default(), ft(t));
}
assert!(
controller.back_interest(),
"a dismissable side sheet claims back interest"
);
controller.request_back();
root.rebuild(&mut app, &mut state);
assert_eq!(
controller.depth(),
2,
"DismissAnimated: unchanged immediately"
);
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 Recorder::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, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"a back request dismisses the sheet"
);
assert_eq!(controller.depth(), 1);
}
#[test]
fn an_actions_dismiss_handle_stages_the_slide_out_instead_of_popping_at_once() {
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, || {
core_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);
let dismiss = ModalDismiss::new();
let installed = dismiss.clone();
show_side_sheet(
&controller,
move || {
side_sheet("Filters", bg_page(100.0, 100.0), Vec::new())
.dismiss_handle(installed.clone())
},
|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 Recorder::default(), ft(t));
}
assert_eq!(controller.depth(), 2, "the sheet is up");
dismiss.dismiss_with(PopResult::of(3));
root.rebuild(&mut app, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft(600));
assert_eq!(
controller.depth(),
2,
"nothing pops while the slide-out runs"
);
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 Recorder::default(), ft(700 + 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, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![Some(3)],
"the action's own result rides the staged pop"
);
assert_eq!(controller.depth(), 1);
}
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 semantics_is_a_modal_dialog_labelled_by_title_with_header_body_and_actions_as_children() {
fn logic(_s: &mut ()) -> SideSheetView<()> {
side_sheet(
"Filters",
leaf_any(100.0, 100.0),
vec![leaf_any(60.0, 32.0)],
)
}
let mut root: RenderRoot<(), SideSheetView<()>> = 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();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Dialog)
.expect("a Role::Dialog node is contributed");
assert!(node.is_modal(), "the side sheet node sets the modal flag");
assert_eq!(node.label(), Some("Filters"));
assert!(
!node.children().is_empty(),
"title/body/actions are semantics children"
);
}
}