1use std::cell::{Cell, OnceCell};
42
43use frus_core::{
44 BorderRadius, BorderRadiusDirectional, Color, Rect, Scene, ShapeBorder, TextDirection,
45};
46use frus_layout::{Dimension, FlexDirection, Style};
47
48use crate::flex::Flex;
49use crate::interaction::Status;
50use crate::portal::Placement;
51use crate::theme::Theme;
52use crate::widget::Widget;
53
54pub const DRAWER_WIDTH: f32 = 304.0;
59
60pub const DRAWER_RADIUS: f32 = 16.0;
67
68struct DrawerPanel<Msg> {
76 children: Vec<Box<dyn Widget<Msg>>>,
77 end: bool,
79 width: Option<f32>,
81 style: PanelStyle,
82}
83
84#[derive(Clone, Copy, Default)]
87struct PanelStyle {
88 background_color: Option<Color>,
89 border_color: Option<Color>,
90 border_width: Option<f32>,
91 radius: Option<f32>,
92 shape: Option<ShapeBorder>,
94 elevation: Option<f32>,
95 shadow_color: Option<Color>,
96}
97
98impl<Msg> DrawerPanel<Msg> {
99 fn resolved_width(&self, theme: Option<&Theme>) -> f32 {
101 self.width
102 .or_else(|| theme.and_then(|t| t.widgets.drawer.width))
103 .unwrap_or(DRAWER_WIDTH)
104 }
105
106 fn docked_right(&self, theme: &Theme) -> bool {
111 self.end != (theme.direction == TextDirection::Rtl)
112 }
113
114 fn inner_edge(&self, r: f32, theme: &Theme) -> BorderRadius {
123 let directional = match self.end {
124 true => BorderRadiusDirectional::start(r),
125 false => BorderRadiusDirectional::end(r),
126 };
127 directional.resolve(theme.direction)
128 }
129
130 fn shape(&self, theme: &Theme) -> ShapeBorder {
134 let t = &theme.widgets.drawer;
135 let themed = match self.end {
136 true => t.end_shape,
137 false => t.shape,
138 };
139 crate::resolve_shape(
140 self.style.shape,
141 themed,
142 self.style
143 .radius
144 .or(t.radius)
145 .map(|r| self.inner_edge(r, theme)),
146 ShapeBorder::rounded(self.inner_edge(DRAWER_RADIUS, theme)),
147 )
148 }
149
150 fn radius(&self, theme: &Theme) -> BorderRadius {
154 self.shape(theme)
155 .as_rounded(Rect::new(0.0, 0.0, self.resolved_width(None), 1000.0))
156 .map(|(_, radius)| radius)
157 .unwrap_or(BorderRadius::ZERO)
158 }
159}
160
161impl<Msg: Clone> Widget<Msg> for DrawerPanel<Msg> {
162 fn style(&self) -> Style {
163 Style {
164 width: Dimension::Length(self.resolved_width(None)),
170 height: Dimension::Percent(1.0),
173 flex_direction: FlexDirection::Column,
174 ..Default::default()
175 }
176 }
177
178 fn style_themed(&self, theme: &Theme) -> Style {
179 Style {
180 width: Dimension::Length(self.resolved_width(Some(theme))),
181 ..Widget::<Msg>::style(self)
182 }
183 }
184
185 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
186 &self.children
187 }
188
189 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
190 let o = status.opacity;
191 let radius = self.radius(theme);
192 let right = self.docked_right(theme);
193 let depth = self
194 .style
195 .elevation
196 .or(theme.widgets.drawer.elevation)
197 .unwrap_or(0.0);
198 let shadow = self
201 .style
202 .shadow_color
203 .or(theme.widgets.drawer.shadow_color)
204 .unwrap_or(Color::TRANSPARENT);
205
206 if depth > 0.0 && shadow.a > 0.0 {
211 frus_core::paint_elevation(scene, bounds, radius, depth, shadow.fade(o));
213 }
214
215 let fill = self
216 .style
217 .background_color
218 .or(theme.widgets.drawer.background_color)
220 .unwrap_or(theme.scheme.surface_container_low);
221 scene.draw_rect(bounds, fill.fade(o), radius, 0.0, Color::TRANSPARENT);
222
223 let thickness = self
229 .style
230 .border_width
231 .or(theme.widgets.drawer.border_width)
232 .unwrap_or(1.0);
233 if thickness > 0.0 {
234 let color = self
235 .style
236 .border_color
237 .or(theme.widgets.drawer.border_color)
238 .unwrap_or(theme.scheme.outline_variant);
239 let x = match right {
240 true => bounds.x,
241 false => bounds.x + bounds.width - thickness,
242 };
243 let inset = radius
244 .top_left
245 .max(radius.top_right)
246 .min(bounds.height / 2.0);
247 scene.fill_rect(
248 Rect::new(x, bounds.y + inset, thickness, bounds.height - 2.0 * inset),
249 color.fade(o),
250 );
251 }
252 }
253
254 fn on_click(&self) -> Option<Msg> {
255 None
256 }
257}
258
259pub struct Drawer<Msg = crate::callback::Callback> {
261 open: bool,
262 right: bool,
263 permanent: bool,
264 on_dismiss: Option<Msg>,
265 panel_content: Cell<Option<Box<dyn Widget<Msg>>>>,
267 body_content: Cell<Option<Box<dyn Widget<Msg>>>>,
269 width: Option<f32>,
271 style: PanelStyle,
273 scrim_color: Option<Color>,
275 assembled: OnceCell<Assembled<Msg>>,
277}
278
279struct Assembled<Msg> {
282 children: Vec<Box<dyn Widget<Msg>>>,
284 modal_panel: Option<Box<dyn Widget<Msg>>>,
286}
287
288impl<Msg: Clone + 'static> Drawer<Msg> {
289 pub fn new(open: bool) -> Self {
291 Self {
292 open,
293 right: false,
294 permanent: false,
295 on_dismiss: None,
296 panel_content: Cell::new(None),
297 body_content: Cell::new(None),
298 width: None,
299 style: PanelStyle::default(),
300 scrim_color: None,
301 assembled: OnceCell::new(),
302 }
303 }
304
305 pub fn width(mut self, width: f32) -> Self {
311 self.width = Some(width);
312 self
313 }
314
315 pub fn background_color(mut self, color: Color) -> Self {
318 self.style.background_color = Some(color);
319 self
320 }
321
322 pub fn border_color(mut self, color: Color) -> Self {
324 self.style.border_color = Some(color);
325 self
326 }
327
328 pub fn border_width(mut self, width: f32) -> Self {
331 self.style.border_width = Some(width);
332 self
333 }
334
335 pub fn radius(mut self, radius: f32) -> Self {
342 self.style.radius = Some(radius);
343 self
344 }
345
346 #[must_use]
355 pub fn shape(mut self, shape: ShapeBorder) -> Self {
356 self.style.shape = Some(shape);
357 self
358 }
359
360 pub fn elevation(mut self, elevation: f32) -> Self {
366 self.style.elevation = Some(elevation);
367 self
368 }
369
370 pub fn shadow_color(mut self, color: Color) -> Self {
373 self.style.shadow_color = Some(color);
374 self
375 }
376
377 pub fn scrim_color(mut self, color: Color) -> Self {
383 self.scrim_color = Some(color);
384 self
385 }
386
387 pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
389 self.on_dismiss = Some(message.into());
390 self
391 }
392
393 pub fn right(mut self) -> Self {
395 self.right = true;
396 self
397 }
398
399 pub fn permanent(mut self, permanent: bool) -> Self {
402 self.permanent = permanent;
403 self
404 }
405
406 pub fn panel(self, content: impl Widget<Msg> + 'static) -> Self {
408 self.panel_content.set(Some(Box::new(content)));
409 self
410 }
411
412 pub fn body(self, body: impl Widget<Msg> + 'static) -> Self {
420 self.body_content.set(Some(Box::new(body)));
421 self
422 }
423
424 fn assembled(&self) -> &Assembled<Msg> {
426 self.assembled.get_or_init(|| {
427 let panel = self.panel_content.take().map(|content| {
428 Box::new(DrawerPanel {
429 children: vec![content],
430 end: self.right,
431 width: self.width,
432 style: self.style,
433 }) as Box<dyn Widget<Msg>>
434 });
435 let Some(body) = self.body_content.take() else {
436 return Assembled {
439 children: Vec::new(),
440 modal_panel: panel.filter(|_| !self.permanent),
441 };
442 };
443
444 if self.permanent {
445 let body_pane: Box<dyn Widget<Msg>> =
447 Box::new(Flex::column().flex(1.0).child_boxed(body));
448 Assembled {
449 children: match panel {
450 Some(panel) if self.right => vec![body_pane, panel],
451 Some(panel) => vec![panel, body_pane],
452 None => vec![body_pane],
453 },
454 modal_panel: None,
455 }
456 } else {
457 Assembled {
459 children: vec![body],
460 modal_panel: panel,
461 }
462 }
463 })
464 }
465}
466
467impl<Msg: Clone + 'static> Widget<Msg> for Drawer<Msg> {
468 fn style(&self) -> Style {
472 Style {
473 height: Dimension::Percent(1.0),
474 flex_direction: if self.permanent {
477 FlexDirection::Row
478 } else {
479 FlexDirection::Column
480 },
481 ..Default::default()
482 }
483 }
484
485 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
487 crate::widget::FillAxes::WIDTH
488 }
489
490 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
491 &self.assembled().children
492 }
493
494 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
495
496 fn on_click(&self) -> Option<Msg> {
497 None
498 }
499
500 fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
501 let placement = if self.right {
504 Placement::Right
505 } else {
506 Placement::Left
507 };
508 self.assembled()
509 .modal_panel
510 .as_ref()
511 .map(|p| (p.as_ref(), placement))
512 }
513
514 fn overlay_dismiss(&self) -> Option<Msg> {
515 self.on_dismiss.clone()
516 }
517
518 fn overlay_scrim(&self, theme: &Theme) -> Option<Color> {
519 self.scrim_color.or(theme.widgets.drawer.scrim_color)
520 }
521
522 fn anim_target(&self) -> Option<f32> {
523 if self.permanent {
526 None
527 } else {
528 Some(if self.open { 1.0 } else { 0.0 })
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::{build_ui, Container, Runtime, Size, Text};
537
538 #[derive(Clone, Debug, PartialEq)]
539 enum Msg {
540 Close,
541 }
542
543 #[test]
544 fn anim_target_reflects_open_state() {
545 let closed = Drawer::new(false)
546 .on_dismiss(Msg::Close)
547 .panel(Text::new("menu"))
548 .body(Container::<Msg>::new());
549 assert_eq!(Widget::<Msg>::anim_target(&closed), Some(0.0));
550 assert!(Widget::<Msg>::overlay(&closed).is_some());
551
552 let open = Drawer::new(true)
553 .on_dismiss(Msg::Close)
554 .panel(Text::new("menu"))
555 .body(Container::<Msg>::new());
556 assert_eq!(Widget::<Msg>::anim_target(&open), Some(1.0));
557 assert_eq!(Widget::<Msg>::overlay_dismiss(&open), Some(Msg::Close));
558 }
559
560 #[test]
561 fn right_drawer_uses_right_placement() {
562 let d = Drawer::new(true)
563 .right()
564 .panel(Text::new("menu"))
565 .body(Container::<Msg>::new());
566 assert!(matches!(
567 Widget::<Msg>::overlay(&d),
568 Some((_, Placement::Right))
569 ));
570 }
571
572 #[test]
573 fn closed_drawer_draws_no_scrim() {
574 let drawer = Drawer::new(false)
575 .on_dismiss(Msg::Close)
576 .panel(Text::new("menu"))
577 .body(Container::<Msg>::new());
578 let ui = build_ui(
579 &drawer,
580 Size::new(500.0, 400.0),
581 &Runtime::default(),
582 &Theme::default(),
583 );
584 let scrim =
585 ui.scene().primitives().iter().any(
586 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 500.0),
587 );
588 assert!(!scrim, "a closed drawer paints no scrim");
589 }
590
591 #[test]
592 fn the_panel_is_as_wide_as_it_was_told_to_be() {
593 let panel_width = |drawer: &dyn Widget<Msg>| {
596 let ui = build_ui(
597 drawer,
598 Size::new(900.0, 400.0),
599 &Runtime::default(),
600 &Theme::default(),
601 );
602 ui.scene()
603 .primitives()
604 .iter()
605 .find_map(|p| match p {
606 frus_core::Primitive::Rect { rect, .. } if rect.height >= 399.0 => {
607 Some(rect.width)
608 }
609 _ => None,
610 })
611 .expect("the panel is a full-height rectangle")
612 };
613 let default = Drawer::new(true)
614 .permanent(true)
615 .panel(Text::new("menu"))
616 .body(Container::<Msg>::new());
617 assert_eq!(panel_width(&default), DRAWER_WIDTH);
618 assert_eq!(DRAWER_WIDTH, 304.0, "the reference's figure");
619
620 let narrow = Drawer::new(true)
621 .permanent(true)
622 .width(96.0)
623 .panel(Text::new("menu"))
624 .body(Container::<Msg>::new());
625 assert_eq!(panel_width(&narrow), 96.0);
626 }
627
628 #[test]
629 fn open_drawer_draws_scrim_and_full_height_panel() {
630 let drawer = Drawer::new(true)
631 .on_dismiss(Msg::Close)
632 .panel(Text::new("menu"))
633 .body(Container::<Msg>::new());
634 let ui = build_ui(
635 &drawer,
636 Size::new(500.0, 400.0),
637 &Runtime::default(),
638 &Theme::default(),
639 );
640 let scrim =
641 ui.scene().primitives().iter().any(
642 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 500.0),
643 );
644 assert!(scrim, "the scrim must cover the window");
645 let panel = ui.scene().primitives().iter().any(|p| {
646 matches!(p, frus_core::Primitive::Rect { rect, .. }
647 if (rect.width - DRAWER_WIDTH).abs() < 1.0 && rect.height >= 399.0)
648 });
649 assert!(panel, "the panel must expand to the full height");
650 }
651
652 #[test]
653 fn mid_animation_slides_panel_and_fades_scrim() {
654 let drawer = Drawer::new(true)
655 .on_dismiss(Msg::Close)
656 .panel(Text::new("menu"))
657 .body(Container::<Msg>::new());
658 let mut rt = Runtime::default();
659 rt.set_value(crate::WidgetId::ROOT, 0.5);
660 let ui = build_ui(&drawer, Size::new(500.0, 400.0), &rt, &Theme::default());
661 let panel_edge = ui.scene().primitives().iter().find_map(|p| match p {
662 frus_core::Primitive::Rect { rect, .. } if (rect.width - DRAWER_WIDTH).abs() < 1.0 => {
663 Some(rect.x + rect.width)
664 }
665 _ => None,
666 });
667 let edge = panel_edge.expect("the drawer's panel must be present");
668 let expected = crate::spring_ease(0.5) * DRAWER_WIDTH;
671 assert!(
672 (edge - expected).abs() < 2.0,
673 "right edge expected ≈ {expected}, got {edge}"
674 );
675 }
676
677 #[test]
678 fn permanent_drawer_docks_panel_beside_body_without_scrim() {
679 let drawer = Drawer::new(false) .permanent(true)
681 .panel(Text::new("menu"))
682 .body(Container::<Msg>::new());
683 assert_eq!(Widget::<Msg>::anim_target(&drawer), None);
685 assert!(Widget::<Msg>::overlay(&drawer).is_none());
686 assert_eq!(
688 Widget::<Msg>::style(&drawer).flex_direction,
689 FlexDirection::Row
690 );
691 assert_eq!(Widget::<Msg>::children(&drawer).len(), 2);
692
693 let ui = build_ui(
694 &drawer,
695 Size::new(900.0, 400.0),
696 &Runtime::default(),
697 &Theme::default(),
698 );
699 let scrim =
701 ui.scene().primitives().iter().any(
702 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 900.0),
703 );
704 assert!(!scrim, "a permanent drawer paints no scrim");
705 let docked = ui.scene().primitives().iter().any(|p| {
707 matches!(p, frus_core::Primitive::Rect { rect, .. }
708 if (rect.width - DRAWER_WIDTH).abs() < 1.0 && rect.x < 1.0 && rect.height >= 399.0)
709 });
710 assert!(docked, "the panel must be docked left, full-height");
711 }
712
713 #[test]
714 fn permanent_right_docks_panel_on_the_right() {
715 let drawer = Drawer::new(false)
716 .permanent(true)
717 .right()
718 .panel(Text::new("menu"))
719 .body(Container::<Msg>::new());
720 assert_eq!(Widget::<Msg>::children(&drawer).len(), 2);
722 let ui = build_ui(
723 &drawer,
724 Size::new(900.0, 400.0),
725 &Runtime::default(),
726 &Theme::default(),
727 );
728 let on_right = ui.scene().primitives().iter().any(|p| {
730 matches!(p, frus_core::Primitive::Rect { rect, .. }
731 if (rect.width - DRAWER_WIDTH).abs() < 1.0 && (rect.x + rect.width - 900.0).abs() < 1.0)
732 });
733 assert!(on_right, "the panel must be docked on the right");
734 }
735
736 fn panel_rect(drawer: &dyn Widget<Msg>, theme: &Theme) -> frus_core::Primitive {
738 let ui = build_ui(drawer, Size::new(900.0, 400.0), &Runtime::default(), theme);
739 ui.scene()
740 .primitives()
741 .iter()
742 .find(|p| {
743 matches!(p, frus_core::Primitive::Rect { rect, radius, blur, .. }
744 if (rect.height - 400.0).abs() < 1.0
745 && *blur == 0.0
746 && *radius != frus_core::BorderRadius::ZERO)
747 })
748 .cloned()
749 .expect("the panel is a full-height rounded rectangle")
750 }
751
752 #[test]
760 fn a_panel_takes_a_whole_shape() {
761 let theme = Theme::default();
762 let corners = |drawer: &dyn Widget<Msg>, theme: &Theme| {
763 let frus_core::Primitive::Rect { radius, .. } = panel_rect(drawer, theme) else {
764 panic!("a rectangle");
765 };
766 radius
767 };
768
769 let told = Drawer::new(false)
771 .permanent(true)
772 .shape(frus_core::ShapeBorder::rounded(
773 frus_core::BorderRadius::top(9.0),
774 ))
775 .panel(Text::new("menu"))
776 .body(Container::<Msg>::new());
777 assert_eq!(corners(&told, &theme), frus_core::BorderRadius::top(9.0));
778
779 let mut themed = Theme::default();
783 themed.widgets.drawer.shape = Some(frus_core::ShapeBorder::rounded(
784 frus_core::BorderRadius::uniform(3.0),
785 ));
786 let leading = Drawer::new(false)
787 .permanent(true)
788 .panel(Text::new("menu"))
789 .body(Container::<Msg>::new());
790 assert_eq!(
791 corners(&leading, &themed),
792 frus_core::BorderRadius::uniform(3.0)
793 );
794
795 let trailing = Drawer::new(false)
796 .permanent(true)
797 .right()
798 .panel(Text::new("menu"))
799 .body(Container::<Msg>::new());
800 assert_eq!(
801 corners(&trailing, &themed),
802 frus_core::BorderRadius::left(DRAWER_RADIUS),
803 "a leading panel's shape says nothing about a trailing one"
804 );
805
806 themed.widgets.drawer.end_shape = Some(frus_core::ShapeBorder::rounded(
807 frus_core::BorderRadius::uniform(5.0),
808 ));
809 assert_eq!(
810 corners(&trailing, &themed),
811 frus_core::BorderRadius::uniform(5.0)
812 );
813 }
814
815 #[test]
819 fn the_inner_edge_is_rounded_and_the_outer_one_is_not() {
820 let theme = Theme::default();
821 let leading = Drawer::new(false)
822 .permanent(true)
823 .panel(Text::new("menu"))
824 .body(Container::<Msg>::new());
825 let frus_core::Primitive::Rect { radius, .. } = panel_rect(&leading, &theme) else {
826 panic!("a rectangle");
827 };
828 assert_eq!(radius, frus_core::BorderRadius::right(DRAWER_RADIUS));
829 assert_eq!(DRAWER_RADIUS, 16.0, "the reference's figure");
830
831 let trailing = Drawer::new(false)
832 .permanent(true)
833 .right()
834 .panel(Text::new("menu"))
835 .body(Container::<Msg>::new());
836 let frus_core::Primitive::Rect { radius, .. } = panel_rect(&trailing, &theme) else {
837 panic!("a rectangle");
838 };
839 assert_eq!(radius, frus_core::BorderRadius::left(DRAWER_RADIUS));
840 }
841
842 #[test]
848 fn the_inner_edge_follows_the_direction_rather_than_the_side_it_was_asked_for() {
849 let rtl = Theme::default().rtl();
850 let leading = Drawer::new(false)
851 .permanent(true)
852 .panel(Text::new("menu"))
853 .body(Container::<Msg>::new());
854 let frus_core::Primitive::Rect { rect, radius, .. } = panel_rect(&leading, &rtl) else {
855 panic!("a rectangle");
856 };
857 assert!(
859 (rect.x + rect.width - 900.0).abs() < 1.0,
860 "a leading panel is on the right in RTL, got x={}",
861 rect.x
862 );
863 assert_eq!(radius, frus_core::BorderRadius::left(DRAWER_RADIUS));
865
866 let ui = build_ui(&leading, Size::new(900.0, 400.0), &Runtime::default(), &rtl);
868 let hairline = ui
869 .scene()
870 .primitives()
871 .iter()
872 .find_map(|p| match p {
873 frus_core::Primitive::Rect { rect, .. } if rect.width <= 1.5 => Some(rect.x),
874 _ => None,
875 })
876 .expect("a hairline");
877 assert!(
878 (hairline - rect.x).abs() < 1.5,
879 "the hairline belongs on the inner edge (x≈{}), got {hairline}",
880 rect.x
881 );
882 }
883
884 #[test]
886 fn the_panels_colours_are_the_instances_then_the_themes() {
887 let mut theme = Theme::default();
888 let fill = |drawer: &dyn Widget<Msg>, theme: &Theme| match panel_rect(drawer, theme) {
889 frus_core::Primitive::Rect { color, .. } => color,
890 _ => unreachable!(),
891 };
892 let plain = || {
893 Drawer::new(false)
894 .permanent(true)
895 .panel(Text::new("menu"))
896 .body(Container::<Msg>::new())
897 };
898 assert_eq!(
899 fill(&plain(), &theme),
900 theme.scheme.surface_container_low,
901 "the ladder's low rung"
902 );
903
904 theme.widgets.drawer.background_color = Some(Color::rgb(0.0, 1.0, 0.0));
905 assert_eq!(
906 fill(&plain(), &theme),
907 Color::rgb(0.0, 1.0, 0.0),
908 "the theme"
909 );
910
911 let own = plain().background_color(Color::rgb(0.0, 0.0, 1.0));
913 assert_eq!(
914 fill(&own, &theme),
915 Color::rgb(0.0, 0.0, 1.0),
916 "the instance wins"
917 );
918 }
919
920 #[test]
923 fn a_zero_border_removes_the_hairline() {
924 let bare = Drawer::new(false)
925 .permanent(true)
926 .border_width(0.0)
927 .panel(Text::new("menu"))
928 .body(Container::<Msg>::new());
929 let ui = build_ui(
930 &bare,
931 Size::new(900.0, 400.0),
932 &Runtime::default(),
933 &Theme::default(),
934 );
935 let slivers = ui
936 .scene()
937 .primitives()
938 .iter()
939 .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width <= 1.5))
940 .count();
941 assert_eq!(slivers, 0, "no rule at all");
942 }
943
944 #[test]
947 fn the_scrim_is_the_drawers_to_colour() {
948 let window = |theme: &Theme, drawer: &dyn Widget<Msg>| {
949 let ui = build_ui(drawer, Size::new(500.0, 400.0), &Runtime::default(), theme);
950 ui.scene()
951 .primitives()
952 .iter()
953 .find_map(|p| match p {
954 frus_core::Primitive::Rect { rect, color, .. } if rect.width >= 500.0 => {
955 Some(*color)
956 }
957 _ => None,
958 })
959 .expect("the scrim covers the window")
960 };
961 let theme = Theme::default();
962 let plain = Drawer::new(true)
963 .on_dismiss(Msg::Close)
964 .panel(Text::new("menu"))
965 .body(Container::<Msg>::new());
966 assert_eq!(window(&theme, &plain), theme.scheme.scrim.with_alpha(0.5));
967
968 let own = Drawer::new(true)
969 .on_dismiss(Msg::Close)
970 .scrim_color(Color::rgba(1.0, 0.0, 0.0, 0.25))
971 .panel(Text::new("menu"))
972 .body(Container::<Msg>::new());
973 assert_eq!(window(&theme, &own), Color::rgba(1.0, 0.0, 0.0, 0.25));
974
975 let clear = Drawer::new(true)
977 .on_dismiss(Msg::Close)
978 .scrim_color(Color::TRANSPARENT)
979 .panel(Text::new("menu"))
980 .body(Container::<Msg>::new());
981 assert_eq!(window(&theme, &clear).a, 0.0);
982 }
983
984 #[test]
987 fn the_scrim_fades_with_the_slide() {
988 let drawer = Drawer::new(true)
989 .on_dismiss(Msg::Close)
990 .scrim_color(Color::rgba(0.0, 0.0, 0.0, 0.8))
991 .panel(Text::new("menu"))
992 .body(Container::<Msg>::new());
993 let mut rt = Runtime::default();
994 rt.set_value(crate::WidgetId::ROOT, 0.5);
995 let ui = build_ui(&drawer, Size::new(500.0, 400.0), &rt, &Theme::default());
996 let alpha = ui
997 .scene()
998 .primitives()
999 .iter()
1000 .find_map(|p| match p {
1001 frus_core::Primitive::Rect { rect, color, .. } if rect.width >= 500.0 => {
1002 Some(color.a)
1003 }
1004 _ => None,
1005 })
1006 .expect("a scrim");
1007 let expected = 0.8 * crate::spring_ease(0.5);
1008 assert!(
1009 (alpha - expected).abs() < 0.02,
1010 "expected ≈ {expected}, got {alpha}"
1011 );
1012 }
1013
1014 #[test]
1017 fn elevation_casts_the_shadow_along_the_inner_edge() {
1018 let shadows = |drawer: &dyn Widget<Msg>| {
1019 let ui = build_ui(
1020 drawer,
1021 Size::new(900.0, 400.0),
1022 &Runtime::default(),
1023 &Theme::default(),
1024 );
1025 ui.scene()
1026 .primitives()
1027 .iter()
1028 .filter_map(|p| match p {
1029 frus_core::Primitive::Rect { rect, blur, .. } if *blur > 0.0 => Some(*rect),
1030 _ => None,
1031 })
1032 .collect::<Vec<_>>()
1033 };
1034 let flat = Drawer::new(false)
1035 .permanent(true)
1036 .panel(Text::new("menu"))
1037 .body(Container::<Msg>::new());
1038 assert!(shadows(&flat).is_empty(), "no shadow by default");
1039
1040 let height_only = Drawer::new(false)
1044 .permanent(true)
1045 .elevation(3.0)
1046 .panel(Text::new("menu"))
1047 .body(Container::<Msg>::new());
1048 assert!(shadows(&height_only).is_empty(), "no colour, no shadow");
1049
1050 let lifted = Drawer::new(false)
1051 .permanent(true)
1052 .elevation(3.0)
1053 .shadow_color(Color::rgba(0.0, 0.0, 0.0, 0.3))
1054 .panel(Text::new("menu"))
1055 .body(Container::<Msg>::new());
1056 let cast = shadows(&lifted);
1057 assert_eq!(cast.len(), 3, "one surface's three shadows");
1058 let panel = match panel_rect(&lifted, &Theme::default()) {
1061 frus_core::Primitive::Rect { rect, .. } => rect,
1062 _ => unreachable!(),
1063 };
1064 let reach = cast.iter().map(|r| r.x + r.width).fold(f32::MIN, f32::max);
1065 assert!(
1066 reach > panel.x + panel.width,
1067 "{reach} past {}",
1068 panel.x + panel.width
1069 );
1070 }
1071
1072 #[test]
1077 fn a_builder_after_body_is_not_a_no_op() {
1078 let theme = Theme::default();
1079 let measured = |drawer: &dyn Widget<Msg>| match panel_rect(drawer, &theme) {
1080 frus_core::Primitive::Rect { rect, color, .. } => (rect.width, color),
1081 _ => unreachable!(),
1082 };
1083 let before = Drawer::new(false)
1084 .permanent(true)
1085 .width(96.0)
1086 .background_color(Color::rgb(0.0, 0.0, 1.0))
1087 .panel(Text::new("menu"))
1088 .body(Container::<Msg>::new());
1089 let after = Drawer::new(false)
1090 .permanent(true)
1091 .panel(Text::new("menu"))
1092 .body(Container::<Msg>::new())
1093 .width(96.0)
1094 .background_color(Color::rgb(0.0, 0.0, 1.0));
1095 assert_eq!(measured(&before), measured(&after));
1096 assert_eq!(measured(&after), (96.0, Color::rgb(0.0, 0.0, 1.0)));
1097 }
1098}