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, ViewSeq, 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>, M>(
433 title: impl Into<String>,
434 body: V,
435 actions: impl ViewSeq<State, M>,
436) -> SideSheetView<State> {
437 let mut erased = Vec::new();
438 actions.extend_views(&mut erased);
439 let title = title.into();
440 let content = SideSheetContentView {
441 title: title.clone(),
442 body: any(body),
443 actions: erased,
444 };
445 SideSheetView(overlay_modal(content, side_sheet_config()).label(title))
446}
447
448impl<State: 'static> SideSheetView<State> {
449 pub fn dismissable(mut self, dismissable: bool) -> Self {
455 self.0 = self.0.dismissable(dismissable);
456 self
457 }
458
459 pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
464 self.0 = self.0.on_dismiss(on_dismiss);
465 self
466 }
467
468 pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
484 self.0 = self.0.dismiss_handle(dismiss);
485 self
486 }
487}
488
489impl<State: 'static> View<State> for SideSheetView<State> {
490 type Element = OverlayModalWidget;
491
492 fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
493 View::build(&self.0, ctx)
494 }
495
496 fn rebuild(
497 &self,
498 prev: &Self,
499 element: &mut OverlayModalWidget,
500 ctx: &mut BuildCtx<'_>,
501 ) -> ChangeFlags {
502 View::rebuild(&self.0, &prev.0, element, ctx)
503 }
504
505 fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
506 View::teardown(&self.0, element, ctx);
507 }
508}
509
510impl<State: 'static> OverlayModalContent<State> for SideSheetView<State> {
511 fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
512 self.0 = self.0.on_modal_dismiss(on_dismiss);
513 self
514 }
515
516 fn modal_dismissable(&self) -> bool {
517 self.0.modal_dismissable()
518 }
519}
520
521pub fn show_side_sheet<State, B, R>(controller: &NavigatorController<State>, build: B, on_result: R)
549where
550 State: 'static,
551 B: Fn() -> SideSheetView<State> + 'static,
552 R: Fn(&mut State, PopResult) + 'static,
553{
554 show_overlay_modal(controller, build, on_result)
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use crate::overlay::{OverlayCorners, OverlayEntrance, OverlayGeometry};
561 use frust::FrameTime;
562 use frust::NavigatorView;
563 use frust::authoring::text::TextContext;
564 use frust::authoring::{
565 BuildCtx, ChangeFlags, CornerRadii, EventCtx, PointerButton, PointerEvent, PointerPhase,
566 Role, Widget, any as core_any,
567 };
568 use frust_core::RenderRoot;
569 use frust_widgets::navigator;
570 use frust_widgets::test_support::leaf_any;
571 use kurbo::Rect;
572 use peniko::Color;
573 use std::any::Any;
574
575 const WINDOW: Size = Size::new(400.0, 600.0);
577
578 #[derive(Default)]
580 struct Recorder {
581 rects: Vec<(Point, Size, Color)>,
582 panels: Vec<(Point, Size, CornerRadii, Color)>,
583 }
584 impl PaintScene for Recorder {
585 fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
586 self.rects.push((origin, size, color));
587 }
588 fn draw_text(&mut self, _origin: Point, _text: &str) {}
589 fn fill_rounded_rect_radii(
590 &mut self,
591 origin: Point,
592 size: Size,
593 radii: CornerRadii,
594 color: Color,
595 ) {
596 self.panels.push((origin, size, radii, color));
597 }
598 fn push_clip_rounded_radii(&mut self, _origin: Point, _size: Size, _radii: CornerRadii) {}
599 fn push_clip(&mut self, _origin: Point, _size: Size) {}
600 fn pop_clip(&mut self) {}
601 }
602
603 fn settled_view<State: 'static, V: View<State>>(
608 title: &str,
609 body: V,
610 actions: Vec<AnyView<State>>,
611 ) -> SideSheetView<State> {
612 let content = SideSheetContentView {
613 title: title.to_string(),
614 body: any(body),
615 actions,
616 };
617 SideSheetView(
618 overlay_modal(content, side_sheet_config().entrance(OverlayEntrance::None))
619 .label(title.to_string()),
620 )
621 }
622
623 fn build<S: 'static>(view: &SideSheetView<S>) -> OverlayModalWidget {
624 let mut counter = 0u64;
625 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
626 }
627
628 fn layout(w: &mut OverlayModalWidget) -> Size {
629 let mut tcx = TextContext::new();
630 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
631 w.layout(&mut lctx, &BoxConstraints::tight(WINDOW))
632 }
633
634 #[test]
637 fn side_sheet_config_pins_a_right_edge_fixed_width_panel_with_left_corners_only() {
638 let config = side_sheet_config();
639 assert_eq!(
640 config.geometry,
641 OverlayGeometry::Edge {
642 side: OverlaySide::Right,
643 extent: OverlayExtent::Content,
644 limit: OverlayLimit::Px(SIDE_SHEET_WIDTH),
645 }
646 );
647 assert_eq!(
648 config.corners,
649 OverlayCorners::Start,
650 "a right-pinned panel rounds its left (screen-facing) corners only"
651 );
652 assert_eq!(
653 config.entrance,
654 OverlayEntrance::Slide,
655 "a side sheet slides in from its own edge"
656 );
657 assert!(
658 config.close_button,
659 "the host's own close affordance is enabled, not a second one"
660 );
661 }
662
663 #[test]
664 fn the_fixed_width_sits_inside_the_m3_side_sheet_spec_band() {
665 assert!(
666 (SIDE_SHEET_MIN_WIDTH..=crate::overlay::OVERLAY_SIDE_SHEET_MAX_WIDTH)
667 .contains(&SIDE_SHEET_WIDTH),
668 "320dp (the reference's own default) must sit inside the M3 [256, 400]dp band"
669 );
670 }
671
672 #[test]
675 fn layout_reserves_the_header_and_fills_the_body_with_no_footer() {
676 let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
677 let mut w = build(&view);
678 let size = layout(&mut w);
679 assert_eq!(size, WINDOW, "the host fills its own area (the scrim)");
680
681 let panel = w.panel_rect();
682 assert_eq!(
683 panel.width(),
684 SIDE_SHEET_WIDTH,
685 "a 400px window is wider than SIDE_SHEET_WIDTH, so no clamp bites"
686 );
687 assert_eq!(panel.height(), WINDOW.height, "full height");
688 assert_eq!(panel.x1, WINDOW.width);
690 }
691
692 #[test]
693 fn layout_adds_a_divider_and_a_left_packed_footer_row_when_actions_is_non_empty() {
694 let view: SideSheetView<()> = settled_view(
695 "Filters",
696 leaf_any(100.0, 100.0),
697 vec![leaf_any(60.0, 32.0), leaf_any(60.0, 32.0)],
698 );
699 let mut w = build(&view);
700 layout(&mut w);
701 let panel_h = w.panel_rect().height();
702
703 let no_actions: SideSheetView<()> =
709 settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
710 let mut w2 = build(&no_actions);
711 layout(&mut w2);
712 assert_eq!(
713 w.panel_rect().height(),
714 w2.panel_rect().height(),
715 "the panel itself is always full height regardless of a footer"
716 );
717 assert_eq!(panel_h, WINDOW.height);
718 }
719
720 fn paint(w: &mut OverlayModalWidget) -> Recorder {
723 let mut rec = Recorder::default();
724 let mut pctx = PaintCtx::new(Point::ZERO, WINDOW);
725 w.paint(&mut pctx, &mut rec);
726 rec
727 }
728
729 #[test]
730 fn unthemed_paint_rounds_only_the_left_corners_at_the_fallback_radius() {
731 let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
732 let mut w = build(&view);
733 layout(&mut w);
734 let rec = paint(&mut w);
735
736 assert_eq!(rec.panels.len(), 1, "one panel fill");
737 let (origin, size, radii, _color) = rec.panels[0];
738 assert_eq!(size.width, SIDE_SHEET_WIDTH);
739 assert_eq!(origin.x, WINDOW.width - SIDE_SHEET_WIDTH, "pinned trailing");
740
741 let radius = crate::overlay::radius(None);
742 assert_eq!(radii.top_left, radius, "left corners round");
743 assert_eq!(radii.bottom_left, radius, "left corners round");
744 assert_eq!(
745 radii.top_right, 0.0,
746 "right corners are square (screen edge)"
747 );
748 assert_eq!(
749 radii.bottom_right, 0.0,
750 "right corners are square (screen edge)"
751 );
752 }
753
754 #[test]
755 fn themed_paint_resolves_the_extra_large_shape_token_on_the_rounded_corners() {
756 let theme = crate::baseline();
757 let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
758 let mut w = build(&view);
759 layout(&mut w);
760
761 let mut rec = Recorder::default();
762 let mut pctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
763 w.paint(&mut pctx, &mut rec);
764
765 let (_, _, radii, _) = rec.panels[0];
766 assert_eq!(radii.top_left, theme.shape.extra_large);
767 assert_eq!(radii.bottom_left, theme.shape.extra_large);
768 assert_eq!(radii.top_right, 0.0);
769 assert_eq!(radii.bottom_right, 0.0);
770 }
771
772 #[test]
775 fn the_real_constructor_starts_off_screen_and_slides_to_the_trailing_edge() {
776 let view: SideSheetView<()> =
777 side_sheet("Filters", leaf_any(100.0, 100.0), Vec::<AnyView<()>>::new());
778 let mut w = build(&view);
779 layout(&mut w);
780 assert_eq!(
781 w.progress(),
782 0.0,
783 "an un-settled Slide entrance starts closed"
784 );
785
786 let mut rec = Recorder::default();
788 let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
789 w.paint(&mut pctx, &mut rec);
790 layout(&mut w);
791 let mut rec2 = Recorder::default();
792 let mut pctx2 =
793 PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(2_000_000_000));
794 w.paint(&mut pctx2, &mut rec2);
795 layout(&mut w);
796
797 assert_eq!(w.progress(), 1.0, "the slide settles fully open");
798 assert_eq!(
799 w.panel_rect().x1,
800 WINDOW.width,
801 "settled, the panel is flush against the trailing edge"
802 );
803 }
804
805 #[derive(Default)]
808 struct Flag {
809 dismissed: u32,
810 }
811
812 fn dispatch(w: &mut OverlayModalWidget, state: &mut Flag, event: &InputEvent) -> EventResult {
813 let state_any: &mut dyn Any = state;
814 let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
815 w.event(&mut ctx, event)
816 }
817
818 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
819 InputEvent::Pointer(PointerEvent {
820 phase,
821 position: Point::new(x, y),
822 button: PointerButton::Primary,
823 })
824 }
825
826 fn settled_flag_view(actions: Vec<AnyView<Flag>>) -> SideSheetView<Flag> {
827 let mut view = settled_view("Filters", leaf_any_flag(100.0, 100.0), actions);
828 view = view.on_dismiss(|s: &mut Flag| s.dismissed += 1);
829 view
830 }
831
832 fn leaf_any_flag(w: f64, h: f64) -> AnyView<Flag> {
835 core_any::<Flag, _>(FixedLeaf {
836 size: Size::new(w, h),
837 })
838 }
839 struct FixedLeaf {
840 size: Size,
841 }
842 struct FixedLeafW {
843 size: Size,
844 }
845 impl View<Flag> for FixedLeaf {
846 type Element = FixedLeafW;
847 fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
848 FixedLeafW { size: self.size }
849 }
850 fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
851 ChangeFlags::NONE
852 }
853 }
854 impl Widget for FixedLeafW {
855 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
856 bc.constrain(self.size)
857 }
858 fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
859 }
860
861 #[test]
862 fn the_hosts_close_affordance_dismisses_via_on_dismiss() {
863 let view = settled_flag_view(Vec::new());
864 let mut w = build(&view);
865 layout(&mut w);
866 let close: Rect = w.close_rect().expect("close_button(true) is configured");
867 let center = close.center();
868
869 let mut state = Flag::default();
870 dispatch(
871 &mut w,
872 &mut state,
873 &ev(PointerPhase::Down, center.x, center.y),
874 );
875 dispatch(
876 &mut w,
877 &mut state,
878 &ev(PointerPhase::Up, center.x, center.y),
879 );
880 assert_eq!(state.dismissed, 1);
881 }
882
883 #[test]
884 fn a_scrim_tap_outside_the_panel_dismisses() {
885 let view = settled_flag_view(Vec::new());
886 let mut w = build(&view);
887 layout(&mut w);
888 assert!(!w.panel_rect().contains(Point::new(5.0, 5.0)));
890
891 let mut state = Flag::default();
892 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
893 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
894 assert_eq!(state.dismissed, 1);
895 }
896
897 #[derive(Default)]
900 struct NavState {
901 results: Vec<Option<i32>>,
902 }
903
904 fn ft(ms: u64) -> FrameTime {
905 FrameTime::from_nanos(ms * 1_000_000)
906 }
907
908 #[test]
909 fn show_side_sheet_stages_a_scrim_tap_dismiss_through_the_navigator() {
910 let controller: NavigatorController<NavState> = NavigatorController::new();
911 let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
912 let mut app = {
913 let ctrl = controller.clone();
914 move |_: &mut NavState| {
915 navigator(&ctrl, || {
916 core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
917 })
918 }
919 };
920 let mut state = NavState::default();
921
922 root.rebuild(&mut app, &mut state);
923 let mut tcx = TextContext::new();
924 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
925
926 show_side_sheet(
927 &controller,
928 || {
929 side_sheet(
930 "Filters",
931 bg_page(100.0, 100.0),
932 Vec::<AnyView<NavState>>::new(),
933 )
934 },
935 |state: &mut NavState, result: PopResult| {
936 state.results.push(result.take::<i32>());
937 },
938 );
939 root.rebuild(&mut app, &mut state);
940 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
941 for t in [0u64, 100, 200, 300, 400, 550] {
943 root.paint(&mut Recorder::default(), ft(t));
944 }
945
946 root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
948 root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
949 root.rebuild(&mut app, &mut state);
950 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
951
952 let mut frames = 0u64;
955 loop {
956 root.rebuild(&mut app, &mut state);
957 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
958 let outcome = root.paint(&mut Recorder::default(), ft(600 + frames * 100));
959 frames += 1;
960 assert!(
961 frames < 30,
962 "the staged exit settles within a bounded number of frames"
963 );
964 if !outcome.needs_frame {
965 break;
966 }
967 }
968 root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
969
970 assert_eq!(
971 state.results,
972 vec![None],
973 "a scrim tap stages the exit and pops with an empty result"
974 );
975 }
976
977 #[test]
978 fn back_request_stages_the_exit_and_pops_via_the_navigator() {
979 let controller: NavigatorController<NavState> = NavigatorController::new();
980 let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
981 let mut app = {
982 let ctrl = controller.clone();
983 move |_: &mut NavState| {
984 navigator(&ctrl, || {
985 core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
986 })
987 }
988 };
989 let mut state = NavState::default();
990
991 root.rebuild(&mut app, &mut state);
992 let mut tcx = TextContext::new();
993 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
994
995 show_side_sheet(
996 &controller,
997 || {
998 side_sheet(
999 "Filters",
1000 bg_page(100.0, 100.0),
1001 Vec::<AnyView<NavState>>::new(),
1002 )
1003 },
1004 |state: &mut NavState, result: PopResult| {
1005 state.results.push(result.take::<i32>());
1006 },
1007 );
1008 root.rebuild(&mut app, &mut state);
1009 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1010 for t in [0u64, 100, 200, 300, 400, 550] {
1011 root.paint(&mut Recorder::default(), ft(t));
1012 }
1013
1014 assert!(
1015 controller.back_interest(),
1016 "a dismissable side sheet claims back interest"
1017 );
1018
1019 controller.request_back();
1020 root.rebuild(&mut app, &mut state);
1021 assert_eq!(
1022 controller.depth(),
1023 2,
1024 "DismissAnimated: unchanged immediately"
1025 );
1026
1027 let mut frames = 0u64;
1028 loop {
1029 root.rebuild(&mut app, &mut state);
1030 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1031 let outcome = root.paint(&mut Recorder::default(), ft(600 + frames * 100));
1032 frames += 1;
1033 assert!(
1034 frames < 30,
1035 "the staged exit settles within a bounded number of frames"
1036 );
1037 if !outcome.needs_frame {
1038 break;
1039 }
1040 }
1041 root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1042
1043 assert_eq!(
1044 state.results,
1045 vec![None],
1046 "a back request dismisses the sheet"
1047 );
1048 assert_eq!(controller.depth(), 1);
1049 }
1050
1051 #[test]
1052 fn an_actions_dismiss_handle_stages_the_slide_out_instead_of_popping_at_once() {
1053 let controller: NavigatorController<NavState> = NavigatorController::new();
1058 let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
1059 let mut app = {
1060 let ctrl = controller.clone();
1061 move |_: &mut NavState| {
1062 navigator(&ctrl, || {
1063 core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
1064 })
1065 }
1066 };
1067 let mut state = NavState::default();
1068
1069 root.rebuild(&mut app, &mut state);
1070 let mut tcx = TextContext::new();
1071 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1072
1073 let dismiss = ModalDismiss::new();
1075 let installed = dismiss.clone();
1076 show_side_sheet(
1077 &controller,
1078 move || {
1079 side_sheet(
1080 "Filters",
1081 bg_page(100.0, 100.0),
1082 Vec::<AnyView<NavState>>::new(),
1083 )
1084 .dismiss_handle(installed.clone())
1085 },
1086 |state: &mut NavState, result: PopResult| {
1087 state.results.push(result.take::<i32>());
1088 },
1089 );
1090 root.rebuild(&mut app, &mut state);
1091 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1092 for t in [0u64, 100, 200, 300, 400, 550] {
1093 root.paint(&mut Recorder::default(), ft(t));
1094 }
1095 assert_eq!(controller.depth(), 2, "the sheet is up");
1096
1097 dismiss.dismiss_with(PopResult::of(3));
1099 root.rebuild(&mut app, &mut state);
1100 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1101 root.paint(&mut Recorder::default(), ft(600));
1102 assert_eq!(
1103 controller.depth(),
1104 2,
1105 "nothing pops while the slide-out runs"
1106 );
1107
1108 let mut frames = 0u64;
1109 loop {
1110 root.rebuild(&mut app, &mut state);
1111 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1112 let outcome = root.paint(&mut Recorder::default(), ft(700 + frames * 100));
1113 frames += 1;
1114 assert!(
1115 frames < 30,
1116 "the staged exit settles within a bounded number of frames"
1117 );
1118 if !outcome.needs_frame {
1119 break;
1120 }
1121 }
1122 root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1123
1124 assert_eq!(
1125 state.results,
1126 vec![Some(3)],
1127 "the action's own result rides the staged pop"
1128 );
1129 assert_eq!(controller.depth(), 1);
1130 }
1131
1132 struct BgPage {
1134 size: Size,
1135 }
1136 struct BgPageW {
1137 size: Size,
1138 }
1139 impl View<NavState> for BgPage {
1140 type Element = BgPageW;
1141 fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageW {
1142 BgPageW { size: self.size }
1143 }
1144 fn rebuild(&self, _p: &Self, _e: &mut BgPageW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1145 ChangeFlags::NONE
1146 }
1147 }
1148 impl Widget for BgPageW {
1149 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1150 bc.constrain(self.size)
1151 }
1152 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1153 scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1154 }
1155 }
1156 fn bg_page(w: f64, h: f64) -> BgPage {
1157 BgPage {
1158 size: Size::new(w, h),
1159 }
1160 }
1161
1162 #[test]
1165 fn semantics_is_a_modal_dialog_labelled_by_title_with_header_body_and_actions_as_children() {
1166 fn logic(_s: &mut ()) -> SideSheetView<()> {
1167 side_sheet(
1168 "Filters",
1169 leaf_any(100.0, 100.0),
1170 vec![leaf_any(60.0, 32.0)],
1171 )
1172 }
1173 let mut root: RenderRoot<(), SideSheetView<()>> = RenderRoot::new();
1174 let mut state = ();
1175 root.rebuild(&mut logic, &mut state);
1176 let mut tcx = TextContext::new();
1177 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1178 let update = root.semantics();
1179 let (_, node) = update
1180 .nodes
1181 .iter()
1182 .find(|(_, n)| n.role() == Role::Dialog)
1183 .expect("a Role::Dialog node is contributed");
1184 assert!(node.is_modal(), "the side sheet node sets the modal flag");
1185 assert_eq!(node.label(), Some("Filters"));
1186 assert!(
1187 !node.children().is_empty(),
1188 "title/body/actions are semantics children"
1189 );
1190 }
1191}