1use std::rc::Rc;
114
115use frust::authoring::text::{FontWeight, LineHeight};
116use frust::authoring::{
117 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
118 LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, ThemeTextType, View, Widget, any,
119};
120use frust::text;
121use kurbo::{Point, Size};
122
123use crate::overlay::{
124 ModalDismiss, OverlayExtent, OverlayLimit, OverlayModalConfig, OverlayModalContent,
125 OverlayModalView, OverlayModalWidget, OverlaySide, overlay_modal, show_overlay_modal,
126};
127use crate::tokens::MaterialSpacing;
128use frust::{NavigatorController, PopResult};
129
130#[allow(dead_code)]
134const SIDE_SHEET_MIN_WIDTH: f64 = 256.0;
135
136pub const SIDE_SHEET_WIDTH: f64 = 320.0;
143
144const HEADER_PAD_LEFT: f64 = MaterialSpacing::XL;
147const HEADER_PAD_TOP: f64 = MaterialSpacing::LG;
150const HEADER_PAD_BOTTOM: f64 = MaterialSpacing::LG;
151const HEADER_PAD_RIGHT: f64 = MaterialSpacing::SM;
154const CLOSE_TAP_TARGET: f64 = 2.0 * MaterialSpacing::MD + 24.0;
161const HEADER_TRAILING_RESERVE: f64 = HEADER_PAD_RIGHT + CLOSE_TAP_TARGET;
164const HEADER_HEIGHT: f64 = HEADER_PAD_TOP + CLOSE_TAP_TARGET + HEADER_PAD_BOTTOM;
168
169const ACTIONS_PADDING: f64 = MaterialSpacing::LG;
172
173const TITLE_SIZE: f32 = 22.0;
182const TITLE_LINE_HEIGHT: f32 = 28.0;
183const TITLE_WEIGHT: FontWeight = FontWeight::REGULAR;
184
185fn side_sheet_config() -> OverlayModalConfig {
192 OverlayModalConfig::edge(OverlaySide::Right)
193 .extent(OverlayExtent::Content, OverlayLimit::Px(SIDE_SHEET_WIDTH))
194 .close_button(true)
195}
196
197fn title_view<State: 'static>(title: &str) -> AnyView<State> {
202 any::<State, _>(
203 text(title.to_string())
204 .size(TITLE_SIZE)
205 .weight(TITLE_WEIGHT)
206 .line_height(LineHeight::Absolute(TITLE_LINE_HEIGHT))
207 .themed_family(ThemeTextType::TitleLarge),
208 )
209}
210
211struct SideSheetContentView<State: 'static> {
214 title: String,
215 body: AnyView<State>,
216 actions: Vec<AnyView<State>>,
217}
218
219impl<State: 'static> View<State> for SideSheetContentView<State> {
220 type Element = SideSheetContentWidget;
221
222 fn build(&self, ctx: &mut BuildCtx<'_>) -> SideSheetContentWidget {
223 SideSheetContentWidget {
224 title: frust::authoring::build_child(&title_view::<State>(&self.title), ctx),
225 body: frust::authoring::build_child(&self.body, ctx),
226 divider: (!self.actions.is_empty()).then(|| {
227 frust::authoring::build_child(&any::<State, _>(crate::divider::divider()), ctx)
228 }),
229 actions: self
230 .actions
231 .iter()
232 .map(|v| frust::authoring::build_child(v, ctx))
233 .collect(),
234 }
235 }
236
237 fn rebuild(
238 &self,
239 prev: &Self,
240 element: &mut SideSheetContentWidget,
241 ctx: &mut BuildCtx<'_>,
242 ) -> ChangeFlags {
243 let mut flags = frust::authoring::rebuild_child(
244 &title_view::<State>(&prev.title),
245 &title_view::<State>(&self.title),
246 &mut element.title,
247 ctx,
248 );
249 flags |= frust::authoring::rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
250
251 match (prev.actions.is_empty(), self.actions.is_empty()) {
252 (true, false) => {
253 element.divider = Some(frust::authoring::build_child(
254 &any::<State, _>(crate::divider::divider()),
255 ctx,
256 ));
257 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
258 }
259 (false, true) => {
260 if let Some(pod) = element.divider.as_mut() {
261 frust::authoring::teardown_child(
262 &any::<State, _>(crate::divider::divider()),
263 pod,
264 ctx,
265 );
266 }
267 element.divider = None;
268 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
269 }
270 _ => {}
271 }
272
273 let prev_views: Vec<&AnyView<State>> = prev.actions.iter().collect();
274 let next_views: Vec<&AnyView<State>> = self.actions.iter().collect();
275 flags |= frust::authoring::rebuild_children(
276 &prev_views,
277 &next_views,
278 &mut element.actions,
279 ctx,
280 |view: &&AnyView<State>| *view,
281 |_| None,
282 );
283
284 flags
285 }
286
287 fn teardown(&self, element: &mut SideSheetContentWidget, ctx: &mut BuildCtx<'_>) {
288 frust::authoring::teardown_child(
289 &title_view::<State>(&self.title),
290 &mut element.title,
291 ctx,
292 );
293 frust::authoring::teardown_child(&self.body, &mut element.body, ctx);
294 if let Some(pod) = element.divider.as_mut() {
295 frust::authoring::teardown_child(&any::<State, _>(crate::divider::divider()), pod, ctx);
296 }
297 for (view, pod) in self.actions.iter().zip(element.actions.iter_mut()) {
298 frust::authoring::teardown_child(view, pod, ctx);
299 }
300 }
301}
302
303struct SideSheetContentWidget {
306 title: ChildPod,
307 body: ChildPod,
308 divider: Option<ChildPod>,
310 actions: Vec<ChildPod>,
311}
312
313impl Widget for SideSheetContentWidget {
314 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
315 let width = crate::overlay::finite_or_zero(bc.max().width);
316 let height = crate::overlay::finite_or_zero(bc.max().height);
317
318 let title_max_w = (width - HEADER_PAD_LEFT - HEADER_TRAILING_RESERVE).max(0.0);
322 let title_size = self.title.layout_child(
323 ctx,
324 &BoxConstraints::loose(Size::new(title_max_w, f64::INFINITY)),
325 );
326 self.title.set_origin(Point::new(
327 HEADER_PAD_LEFT,
328 ((HEADER_HEIGHT - title_size.height) / 2.0).max(0.0),
329 ));
330
331 let mut footer_h = 0.0;
334 if let Some(divider) = self.divider.as_mut() {
335 let divider_size =
336 divider.layout_child(ctx, &BoxConstraints::loose(Size::new(width, height)));
337
338 let action_bc = BoxConstraints::loose(Size::new(
339 (width - 2.0 * ACTIONS_PADDING).max(0.0),
340 f64::INFINITY,
341 ));
342 let mut actions_h: f64 = 0.0;
343 for pod in self.actions.iter_mut() {
344 let s = pod.layout_child(ctx, &action_bc);
345 actions_h = actions_h.max(s.height);
346 }
347
348 footer_h = divider_size.height + 2.0 * ACTIONS_PADDING + actions_h;
349 let footer_y = height - footer_h;
350 divider.set_origin(Point::new(0.0, footer_y));
351
352 let mut ax = ACTIONS_PADDING;
356 let actions_y = footer_y + divider_size.height + ACTIONS_PADDING;
357 for pod in self.actions.iter_mut() {
358 let s = pod.size();
359 pod.set_origin(Point::new(ax, actions_y));
360 ax += s.width;
361 }
362 }
363
364 let body_h = (height - HEADER_HEIGHT - footer_h).max(0.0);
368 self.body
369 .layout_child(ctx, &BoxConstraints::tight(Size::new(width, body_h)));
370 self.body.set_origin(Point::new(0.0, HEADER_HEIGHT));
371
372 Size::new(width, height)
373 }
374
375 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
376 self.title.paint_child(ctx, scene);
377 self.body.paint_child(ctx, scene);
378 if let Some(divider) = self.divider.as_mut() {
379 divider.paint_child(ctx, scene);
380 }
381 for pod in &mut self.actions {
382 pod.paint_child(ctx, scene);
383 }
384 }
385
386 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
387 if event.is_broadcast() {
388 self.body.event_child(ctx, event);
389 for pod in &mut self.actions {
390 pod.event_child(ctx, event);
391 }
392 return EventResult::Ignored;
393 }
394 if frust::authoring::route_event_single(&mut self.body, ctx, event) == EventResult::Handled
395 {
396 return EventResult::Handled;
397 }
398 frust::authoring::route_event(&mut self.actions, ctx, event)
399 }
400
401 fn semantics(&self, ctx: &mut SemanticsCtx) {
402 self.title.semantics_child(ctx);
403 self.body.semantics_child(ctx);
404 if let Some(divider) = &self.divider {
405 divider.semantics_child(ctx);
406 }
407 for pod in &self.actions {
408 pod.semantics_child(ctx);
409 }
410 }
411
412 frust::authoring::visit_children!(title, body, divider, actions);
413}
414
415pub struct SideSheetView<State: 'static>(OverlayModalView<State>);
420
421pub fn side_sheet<State: 'static, V: View<State>>(
433 title: impl Into<String>,
434 body: V,
435 actions: Vec<AnyView<State>>,
436) -> SideSheetView<State> {
437 let title = title.into();
438 let content = SideSheetContentView {
439 title: title.clone(),
440 body: any(body),
441 actions,
442 };
443 SideSheetView(overlay_modal(content, side_sheet_config()).label(title))
444}
445
446impl<State: 'static> SideSheetView<State> {
447 pub fn dismissable(mut self, dismissable: bool) -> Self {
453 self.0 = self.0.dismissable(dismissable);
454 self
455 }
456
457 pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
462 self.0 = self.0.on_dismiss(on_dismiss);
463 self
464 }
465
466 pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
482 self.0 = self.0.dismiss_handle(dismiss);
483 self
484 }
485}
486
487impl<State: 'static> View<State> for SideSheetView<State> {
488 type Element = OverlayModalWidget;
489
490 fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
491 View::build(&self.0, ctx)
492 }
493
494 fn rebuild(
495 &self,
496 prev: &Self,
497 element: &mut OverlayModalWidget,
498 ctx: &mut BuildCtx<'_>,
499 ) -> ChangeFlags {
500 View::rebuild(&self.0, &prev.0, element, ctx)
501 }
502
503 fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
504 View::teardown(&self.0, element, ctx);
505 }
506}
507
508impl<State: 'static> OverlayModalContent<State> for SideSheetView<State> {
509 fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
510 self.0 = self.0.on_modal_dismiss(on_dismiss);
511 self
512 }
513
514 fn modal_dismissable(&self) -> bool {
515 self.0.modal_dismissable()
516 }
517}
518
519pub fn show_side_sheet<State, B, R>(controller: &NavigatorController<State>, build: B, on_result: R)
541where
542 State: 'static,
543 B: Fn() -> SideSheetView<State> + 'static,
544 R: Fn(&mut State, PopResult) + 'static,
545{
546 show_overlay_modal(controller, build, on_result)
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::overlay::{OverlayCorners, OverlayEntrance, OverlayGeometry};
553 use frust::FrameTime;
554 use frust::NavigatorView;
555 use frust::authoring::text::TextContext;
556 use frust::authoring::{
557 BuildCtx, ChangeFlags, CornerRadii, EventCtx, PointerButton, PointerEvent, PointerPhase,
558 Role, Widget, any as core_any,
559 };
560 use frust_core::RenderRoot;
561 use frust_widgets::navigator;
562 use frust_widgets::test_support::leaf_any;
563 use kurbo::Rect;
564 use peniko::Color;
565 use std::any::Any;
566
567 const WINDOW: Size = Size::new(400.0, 600.0);
569
570 #[derive(Default)]
572 struct Recorder {
573 rects: Vec<(Point, Size, Color)>,
574 panels: Vec<(Point, Size, CornerRadii, Color)>,
575 }
576 impl PaintScene for Recorder {
577 fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
578 self.rects.push((origin, size, color));
579 }
580 fn draw_text(&mut self, _origin: Point, _text: &str) {}
581 fn fill_rounded_rect_radii(
582 &mut self,
583 origin: Point,
584 size: Size,
585 radii: CornerRadii,
586 color: Color,
587 ) {
588 self.panels.push((origin, size, radii, color));
589 }
590 fn push_clip_rounded_radii(&mut self, _origin: Point, _size: Size, _radii: CornerRadii) {}
591 fn push_clip(&mut self, _origin: Point, _size: Size) {}
592 fn pop_clip(&mut self) {}
593 }
594
595 fn settled_view<State: 'static, V: View<State>>(
600 title: &str,
601 body: V,
602 actions: Vec<AnyView<State>>,
603 ) -> SideSheetView<State> {
604 let content = SideSheetContentView {
605 title: title.to_string(),
606 body: any(body),
607 actions,
608 };
609 SideSheetView(
610 overlay_modal(content, side_sheet_config().entrance(OverlayEntrance::None))
611 .label(title.to_string()),
612 )
613 }
614
615 fn build<S: 'static>(view: &SideSheetView<S>) -> OverlayModalWidget {
616 let mut counter = 0u64;
617 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
618 }
619
620 fn layout(w: &mut OverlayModalWidget) -> Size {
621 let mut tcx = TextContext::new();
622 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
623 w.layout(&mut lctx, &BoxConstraints::tight(WINDOW))
624 }
625
626 #[test]
629 fn side_sheet_config_pins_a_right_edge_fixed_width_panel_with_left_corners_only() {
630 let config = side_sheet_config();
631 assert_eq!(
632 config.geometry,
633 OverlayGeometry::Edge {
634 side: OverlaySide::Right,
635 extent: OverlayExtent::Content,
636 limit: OverlayLimit::Px(SIDE_SHEET_WIDTH),
637 }
638 );
639 assert_eq!(
640 config.corners,
641 OverlayCorners::Start,
642 "a right-pinned panel rounds its left (screen-facing) corners only"
643 );
644 assert_eq!(
645 config.entrance,
646 OverlayEntrance::Slide,
647 "a side sheet slides in from its own edge"
648 );
649 assert!(
650 config.close_button,
651 "the host's own close affordance is enabled, not a second one"
652 );
653 }
654
655 #[test]
656 fn the_fixed_width_sits_inside_the_m3_side_sheet_spec_band() {
657 assert!(
658 (SIDE_SHEET_MIN_WIDTH..=crate::overlay::OVERLAY_SIDE_SHEET_MAX_WIDTH)
659 .contains(&SIDE_SHEET_WIDTH),
660 "320dp (the reference's own default) must sit inside the M3 [256, 400]dp band"
661 );
662 }
663
664 #[test]
667 fn layout_reserves_the_header_and_fills_the_body_with_no_footer() {
668 let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
669 let mut w = build(&view);
670 let size = layout(&mut w);
671 assert_eq!(size, WINDOW, "the host fills its own area (the scrim)");
672
673 let panel = w.panel_rect();
674 assert_eq!(
675 panel.width(),
676 SIDE_SHEET_WIDTH,
677 "a 400px window is wider than SIDE_SHEET_WIDTH, so no clamp bites"
678 );
679 assert_eq!(panel.height(), WINDOW.height, "full height");
680 assert_eq!(panel.x1, WINDOW.width);
682 }
683
684 #[test]
685 fn layout_adds_a_divider_and_a_left_packed_footer_row_when_actions_is_non_empty() {
686 let view: SideSheetView<()> = settled_view(
687 "Filters",
688 leaf_any(100.0, 100.0),
689 vec![leaf_any(60.0, 32.0), leaf_any(60.0, 32.0)],
690 );
691 let mut w = build(&view);
692 layout(&mut w);
693 let panel_h = w.panel_rect().height();
694
695 let no_actions: SideSheetView<()> =
701 settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
702 let mut w2 = build(&no_actions);
703 layout(&mut w2);
704 assert_eq!(
705 w.panel_rect().height(),
706 w2.panel_rect().height(),
707 "the panel itself is always full height regardless of a footer"
708 );
709 assert_eq!(panel_h, WINDOW.height);
710 }
711
712 fn paint(w: &mut OverlayModalWidget) -> Recorder {
715 let mut rec = Recorder::default();
716 let mut pctx = PaintCtx::new(Point::ZERO, WINDOW);
717 w.paint(&mut pctx, &mut rec);
718 rec
719 }
720
721 #[test]
722 fn unthemed_paint_rounds_only_the_left_corners_at_the_fallback_radius() {
723 let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
724 let mut w = build(&view);
725 layout(&mut w);
726 let rec = paint(&mut w);
727
728 assert_eq!(rec.panels.len(), 1, "one panel fill");
729 let (origin, size, radii, _color) = rec.panels[0];
730 assert_eq!(size.width, SIDE_SHEET_WIDTH);
731 assert_eq!(origin.x, WINDOW.width - SIDE_SHEET_WIDTH, "pinned trailing");
732
733 let radius = crate::overlay::radius(None);
734 assert_eq!(radii.top_left, radius, "left corners round");
735 assert_eq!(radii.bottom_left, radius, "left corners round");
736 assert_eq!(
737 radii.top_right, 0.0,
738 "right corners are square (screen edge)"
739 );
740 assert_eq!(
741 radii.bottom_right, 0.0,
742 "right corners are square (screen edge)"
743 );
744 }
745
746 #[test]
747 fn themed_paint_resolves_the_extra_large_shape_token_on_the_rounded_corners() {
748 let theme = crate::baseline();
749 let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
750 let mut w = build(&view);
751 layout(&mut w);
752
753 let mut rec = Recorder::default();
754 let mut pctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
755 w.paint(&mut pctx, &mut rec);
756
757 let (_, _, radii, _) = rec.panels[0];
758 assert_eq!(radii.top_left, theme.shape.extra_large);
759 assert_eq!(radii.bottom_left, theme.shape.extra_large);
760 assert_eq!(radii.top_right, 0.0);
761 assert_eq!(radii.bottom_right, 0.0);
762 }
763
764 #[test]
767 fn the_real_constructor_starts_off_screen_and_slides_to_the_trailing_edge() {
768 let view: SideSheetView<()> = side_sheet("Filters", leaf_any(100.0, 100.0), Vec::new());
769 let mut w = build(&view);
770 layout(&mut w);
771 assert_eq!(
772 w.progress(),
773 0.0,
774 "an un-settled Slide entrance starts closed"
775 );
776
777 let mut rec = Recorder::default();
779 let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
780 w.paint(&mut pctx, &mut rec);
781 layout(&mut w);
782 let mut rec2 = Recorder::default();
783 let mut pctx2 =
784 PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(2_000_000_000));
785 w.paint(&mut pctx2, &mut rec2);
786 layout(&mut w);
787
788 assert_eq!(w.progress(), 1.0, "the slide settles fully open");
789 assert_eq!(
790 w.panel_rect().x1,
791 WINDOW.width,
792 "settled, the panel is flush against the trailing edge"
793 );
794 }
795
796 #[derive(Default)]
799 struct Flag {
800 dismissed: u32,
801 }
802
803 fn dispatch(w: &mut OverlayModalWidget, state: &mut Flag, event: &InputEvent) -> EventResult {
804 let state_any: &mut dyn Any = state;
805 let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
806 w.event(&mut ctx, event)
807 }
808
809 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
810 InputEvent::Pointer(PointerEvent {
811 phase,
812 position: Point::new(x, y),
813 button: PointerButton::Primary,
814 })
815 }
816
817 fn settled_flag_view(actions: Vec<AnyView<Flag>>) -> SideSheetView<Flag> {
818 let mut view = settled_view("Filters", leaf_any_flag(100.0, 100.0), actions);
819 view = view.on_dismiss(|s: &mut Flag| s.dismissed += 1);
820 view
821 }
822
823 fn leaf_any_flag(w: f64, h: f64) -> AnyView<Flag> {
826 core_any::<Flag, _>(FixedLeaf {
827 size: Size::new(w, h),
828 })
829 }
830 struct FixedLeaf {
831 size: Size,
832 }
833 struct FixedLeafW {
834 size: Size,
835 }
836 impl View<Flag> for FixedLeaf {
837 type Element = FixedLeafW;
838 fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
839 FixedLeafW { size: self.size }
840 }
841 fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
842 ChangeFlags::NONE
843 }
844 }
845 impl Widget for FixedLeafW {
846 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
847 bc.constrain(self.size)
848 }
849 fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
850 }
851
852 #[test]
853 fn the_hosts_close_affordance_dismisses_via_on_dismiss() {
854 let view = settled_flag_view(Vec::new());
855 let mut w = build(&view);
856 layout(&mut w);
857 let close: Rect = w.close_rect().expect("close_button(true) is configured");
858 let center = close.center();
859
860 let mut state = Flag::default();
861 dispatch(
862 &mut w,
863 &mut state,
864 &ev(PointerPhase::Down, center.x, center.y),
865 );
866 dispatch(
867 &mut w,
868 &mut state,
869 &ev(PointerPhase::Up, center.x, center.y),
870 );
871 assert_eq!(state.dismissed, 1);
872 }
873
874 #[test]
875 fn a_scrim_tap_outside_the_panel_dismisses() {
876 let view = settled_flag_view(Vec::new());
877 let mut w = build(&view);
878 layout(&mut w);
879 assert!(!w.panel_rect().contains(Point::new(5.0, 5.0)));
881
882 let mut state = Flag::default();
883 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
884 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
885 assert_eq!(state.dismissed, 1);
886 }
887
888 #[derive(Default)]
891 struct NavState {
892 results: Vec<Option<i32>>,
893 }
894
895 fn ft(ms: u64) -> FrameTime {
896 FrameTime::from_nanos(ms * 1_000_000)
897 }
898
899 #[test]
900 fn show_side_sheet_stages_a_scrim_tap_dismiss_through_the_navigator() {
901 let controller: NavigatorController<NavState> = NavigatorController::new();
902 let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
903 let mut app = {
904 let ctrl = controller.clone();
905 move |_: &mut NavState| {
906 navigator(&ctrl, || {
907 core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
908 })
909 }
910 };
911 let mut state = NavState::default();
912
913 root.rebuild(&mut app, &mut state);
914 let mut tcx = TextContext::new();
915 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
916
917 show_side_sheet(
918 &controller,
919 || side_sheet("Filters", bg_page(100.0, 100.0), Vec::new()),
920 |state: &mut NavState, result: PopResult| {
921 state.results.push(result.take::<i32>());
922 },
923 );
924 root.rebuild(&mut app, &mut state);
925 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
926 for t in [0u64, 100, 200, 300, 400, 550] {
928 root.paint(&mut Recorder::default(), ft(t));
929 }
930
931 root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
933 root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
934 root.rebuild(&mut app, &mut state);
935 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
936
937 let mut frames = 0u64;
940 loop {
941 root.rebuild(&mut app, &mut state);
942 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
943 let outcome = root.paint(&mut Recorder::default(), ft(600 + frames * 100));
944 frames += 1;
945 assert!(
946 frames < 30,
947 "the staged exit settles within a bounded number of frames"
948 );
949 if !outcome.needs_frame {
950 break;
951 }
952 }
953 root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
954
955 assert_eq!(
956 state.results,
957 vec![None],
958 "a scrim tap stages the exit and pops with an empty result"
959 );
960 }
961
962 #[test]
963 fn back_request_stages_the_exit_and_pops_via_the_navigator() {
964 let controller: NavigatorController<NavState> = NavigatorController::new();
965 let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
966 let mut app = {
967 let ctrl = controller.clone();
968 move |_: &mut NavState| {
969 navigator(&ctrl, || {
970 core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
971 })
972 }
973 };
974 let mut state = NavState::default();
975
976 root.rebuild(&mut app, &mut state);
977 let mut tcx = TextContext::new();
978 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
979
980 show_side_sheet(
981 &controller,
982 || side_sheet("Filters", bg_page(100.0, 100.0), Vec::new()),
983 |state: &mut NavState, result: PopResult| {
984 state.results.push(result.take::<i32>());
985 },
986 );
987 root.rebuild(&mut app, &mut state);
988 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
989 for t in [0u64, 100, 200, 300, 400, 550] {
990 root.paint(&mut Recorder::default(), ft(t));
991 }
992
993 assert!(
994 controller.back_interest(),
995 "a dismissable side sheet claims back interest"
996 );
997
998 controller.request_back();
999 root.rebuild(&mut app, &mut state);
1000 assert_eq!(
1001 controller.depth(),
1002 2,
1003 "DismissAnimated: unchanged immediately"
1004 );
1005
1006 let mut frames = 0u64;
1007 loop {
1008 root.rebuild(&mut app, &mut state);
1009 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1010 let outcome = root.paint(&mut Recorder::default(), ft(600 + frames * 100));
1011 frames += 1;
1012 assert!(
1013 frames < 30,
1014 "the staged exit settles within a bounded number of frames"
1015 );
1016 if !outcome.needs_frame {
1017 break;
1018 }
1019 }
1020 root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1021
1022 assert_eq!(
1023 state.results,
1024 vec![None],
1025 "a back request dismisses the sheet"
1026 );
1027 assert_eq!(controller.depth(), 1);
1028 }
1029
1030 #[test]
1031 fn an_actions_dismiss_handle_stages_the_slide_out_instead_of_popping_at_once() {
1032 let controller: NavigatorController<NavState> = NavigatorController::new();
1037 let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
1038 let mut app = {
1039 let ctrl = controller.clone();
1040 move |_: &mut NavState| {
1041 navigator(&ctrl, || {
1042 core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
1043 })
1044 }
1045 };
1046 let mut state = NavState::default();
1047
1048 root.rebuild(&mut app, &mut state);
1049 let mut tcx = TextContext::new();
1050 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1051
1052 let dismiss = ModalDismiss::new();
1054 let installed = dismiss.clone();
1055 show_side_sheet(
1056 &controller,
1057 move || {
1058 side_sheet("Filters", bg_page(100.0, 100.0), Vec::new())
1059 .dismiss_handle(installed.clone())
1060 },
1061 |state: &mut NavState, result: PopResult| {
1062 state.results.push(result.take::<i32>());
1063 },
1064 );
1065 root.rebuild(&mut app, &mut state);
1066 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1067 for t in [0u64, 100, 200, 300, 400, 550] {
1068 root.paint(&mut Recorder::default(), ft(t));
1069 }
1070 assert_eq!(controller.depth(), 2, "the sheet is up");
1071
1072 dismiss.dismiss_with(PopResult::of(3));
1074 root.rebuild(&mut app, &mut state);
1075 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1076 root.paint(&mut Recorder::default(), ft(600));
1077 assert_eq!(
1078 controller.depth(),
1079 2,
1080 "nothing pops while the slide-out runs"
1081 );
1082
1083 let mut frames = 0u64;
1084 loop {
1085 root.rebuild(&mut app, &mut state);
1086 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1087 let outcome = root.paint(&mut Recorder::default(), ft(700 + frames * 100));
1088 frames += 1;
1089 assert!(
1090 frames < 30,
1091 "the staged exit settles within a bounded number of frames"
1092 );
1093 if !outcome.needs_frame {
1094 break;
1095 }
1096 }
1097 root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1098
1099 assert_eq!(
1100 state.results,
1101 vec![Some(3)],
1102 "the action's own result rides the staged pop"
1103 );
1104 assert_eq!(controller.depth(), 1);
1105 }
1106
1107 struct BgPage {
1109 size: Size,
1110 }
1111 struct BgPageW {
1112 size: Size,
1113 }
1114 impl View<NavState> for BgPage {
1115 type Element = BgPageW;
1116 fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageW {
1117 BgPageW { size: self.size }
1118 }
1119 fn rebuild(&self, _p: &Self, _e: &mut BgPageW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1120 ChangeFlags::NONE
1121 }
1122 }
1123 impl Widget for BgPageW {
1124 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1125 bc.constrain(self.size)
1126 }
1127 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1128 scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1129 }
1130 }
1131 fn bg_page(w: f64, h: f64) -> BgPage {
1132 BgPage {
1133 size: Size::new(w, h),
1134 }
1135 }
1136
1137 #[test]
1140 fn semantics_is_a_modal_dialog_labelled_by_title_with_header_body_and_actions_as_children() {
1141 fn logic(_s: &mut ()) -> SideSheetView<()> {
1142 side_sheet(
1143 "Filters",
1144 leaf_any(100.0, 100.0),
1145 vec![leaf_any(60.0, 32.0)],
1146 )
1147 }
1148 let mut root: RenderRoot<(), SideSheetView<()>> = RenderRoot::new();
1149 let mut state = ();
1150 root.rebuild(&mut logic, &mut state);
1151 let mut tcx = TextContext::new();
1152 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1153 let update = root.semantics();
1154 let (_, node) = update
1155 .nodes
1156 .iter()
1157 .find(|(_, n)| n.role() == Role::Dialog)
1158 .expect("a Role::Dialog node is contributed");
1159 assert!(node.is_modal(), "the side sheet node sets the modal flag");
1160 assert_eq!(node.label(), Some("Filters"));
1161 assert!(
1162 !node.children().is_empty(),
1163 "title/body/actions are semantics children"
1164 );
1165 }
1166}