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 let blur = depth * 4.0 + 8.0;
212 let sideways = if right { -depth } else { depth } * 2.0;
213 scene.shadow(
214 Rect::new(
215 bounds.x + sideways - blur,
216 bounds.y - blur,
217 bounds.width + 2.0 * blur,
218 bounds.height + 2.0 * blur,
219 ),
220 shadow.fade(o),
221 radius.inflate(blur),
222 blur,
223 );
224 }
225
226 let fill = self
227 .style
228 .background_color
229 .or(theme.widgets.drawer.background_color)
231 .unwrap_or(theme.scheme.surface_container_low);
232 scene.draw_rect(bounds, fill.fade(o), radius, 0.0, Color::TRANSPARENT);
233
234 let thickness = self
240 .style
241 .border_width
242 .or(theme.widgets.drawer.border_width)
243 .unwrap_or(1.0);
244 if thickness > 0.0 {
245 let color = self
246 .style
247 .border_color
248 .or(theme.widgets.drawer.border_color)
249 .unwrap_or(theme.scheme.outline_variant);
250 let x = match right {
251 true => bounds.x,
252 false => bounds.x + bounds.width - thickness,
253 };
254 let inset = radius
255 .top_left
256 .max(radius.top_right)
257 .min(bounds.height / 2.0);
258 scene.fill_rect(
259 Rect::new(x, bounds.y + inset, thickness, bounds.height - 2.0 * inset),
260 color.fade(o),
261 );
262 }
263 }
264
265 fn on_click(&self) -> Option<Msg> {
266 None
267 }
268}
269
270pub struct Drawer<Msg = crate::callback::Callback> {
272 open: bool,
273 right: bool,
274 permanent: bool,
275 on_dismiss: Option<Msg>,
276 panel_content: Cell<Option<Box<dyn Widget<Msg>>>>,
278 body_content: Cell<Option<Box<dyn Widget<Msg>>>>,
280 width: Option<f32>,
282 style: PanelStyle,
284 scrim_color: Option<Color>,
286 assembled: OnceCell<Assembled<Msg>>,
288}
289
290struct Assembled<Msg> {
293 children: Vec<Box<dyn Widget<Msg>>>,
295 modal_panel: Option<Box<dyn Widget<Msg>>>,
297}
298
299impl<Msg: Clone + 'static> Drawer<Msg> {
300 pub fn new(open: bool) -> Self {
302 Self {
303 open,
304 right: false,
305 permanent: false,
306 on_dismiss: None,
307 panel_content: Cell::new(None),
308 body_content: Cell::new(None),
309 width: None,
310 style: PanelStyle::default(),
311 scrim_color: None,
312 assembled: OnceCell::new(),
313 }
314 }
315
316 pub fn width(mut self, width: f32) -> Self {
322 self.width = Some(width);
323 self
324 }
325
326 pub fn background_color(mut self, color: Color) -> Self {
329 self.style.background_color = Some(color);
330 self
331 }
332
333 pub fn border_color(mut self, color: Color) -> Self {
335 self.style.border_color = Some(color);
336 self
337 }
338
339 pub fn border_width(mut self, width: f32) -> Self {
342 self.style.border_width = Some(width);
343 self
344 }
345
346 pub fn radius(mut self, radius: f32) -> Self {
353 self.style.radius = Some(radius);
354 self
355 }
356
357 #[must_use]
366 pub fn shape(mut self, shape: ShapeBorder) -> Self {
367 self.style.shape = Some(shape);
368 self
369 }
370
371 pub fn elevation(mut self, elevation: f32) -> Self {
377 self.style.elevation = Some(elevation);
378 self
379 }
380
381 pub fn shadow_color(mut self, color: Color) -> Self {
384 self.style.shadow_color = Some(color);
385 self
386 }
387
388 pub fn scrim_color(mut self, color: Color) -> Self {
394 self.scrim_color = Some(color);
395 self
396 }
397
398 pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
400 self.on_dismiss = Some(message.into());
401 self
402 }
403
404 pub fn right(mut self) -> Self {
406 self.right = true;
407 self
408 }
409
410 pub fn permanent(mut self, permanent: bool) -> Self {
413 self.permanent = permanent;
414 self
415 }
416
417 pub fn panel(self, content: impl Widget<Msg> + 'static) -> Self {
419 self.panel_content.set(Some(Box::new(content)));
420 self
421 }
422
423 pub fn body(self, body: impl Widget<Msg> + 'static) -> Self {
431 self.body_content.set(Some(Box::new(body)));
432 self
433 }
434
435 fn assembled(&self) -> &Assembled<Msg> {
437 self.assembled.get_or_init(|| {
438 let panel = self.panel_content.take().map(|content| {
439 Box::new(DrawerPanel {
440 children: vec![content],
441 end: self.right,
442 width: self.width,
443 style: self.style,
444 }) as Box<dyn Widget<Msg>>
445 });
446 let Some(body) = self.body_content.take() else {
447 return Assembled {
450 children: Vec::new(),
451 modal_panel: panel.filter(|_| !self.permanent),
452 };
453 };
454
455 if self.permanent {
456 let body_pane: Box<dyn Widget<Msg>> =
458 Box::new(Flex::column().flex(1.0).child_boxed(body));
459 Assembled {
460 children: match panel {
461 Some(panel) if self.right => vec![body_pane, panel],
462 Some(panel) => vec![panel, body_pane],
463 None => vec![body_pane],
464 },
465 modal_panel: None,
466 }
467 } else {
468 Assembled {
470 children: vec![body],
471 modal_panel: panel,
472 }
473 }
474 })
475 }
476}
477
478impl<Msg: Clone + 'static> Widget<Msg> for Drawer<Msg> {
479 fn style(&self) -> Style {
483 Style {
484 height: Dimension::Percent(1.0),
485 flex_direction: if self.permanent {
488 FlexDirection::Row
489 } else {
490 FlexDirection::Column
491 },
492 ..Default::default()
493 }
494 }
495
496 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
498 crate::widget::FillAxes::WIDTH
499 }
500
501 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
502 &self.assembled().children
503 }
504
505 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
506
507 fn on_click(&self) -> Option<Msg> {
508 None
509 }
510
511 fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
512 let placement = if self.right {
515 Placement::Right
516 } else {
517 Placement::Left
518 };
519 self.assembled()
520 .modal_panel
521 .as_ref()
522 .map(|p| (p.as_ref(), placement))
523 }
524
525 fn overlay_dismiss(&self) -> Option<Msg> {
526 self.on_dismiss.clone()
527 }
528
529 fn overlay_scrim(&self, theme: &Theme) -> Option<Color> {
530 self.scrim_color.or(theme.widgets.drawer.scrim_color)
531 }
532
533 fn anim_target(&self) -> Option<f32> {
534 if self.permanent {
537 None
538 } else {
539 Some(if self.open { 1.0 } else { 0.0 })
540 }
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::{build_ui, Container, Runtime, Size, Text};
548
549 #[derive(Clone, Debug, PartialEq)]
550 enum Msg {
551 Close,
552 }
553
554 #[test]
555 fn anim_target_reflects_open_state() {
556 let closed = Drawer::new(false)
557 .on_dismiss(Msg::Close)
558 .panel(Text::new("menu"))
559 .body(Container::<Msg>::new());
560 assert_eq!(Widget::<Msg>::anim_target(&closed), Some(0.0));
561 assert!(Widget::<Msg>::overlay(&closed).is_some());
562
563 let open = Drawer::new(true)
564 .on_dismiss(Msg::Close)
565 .panel(Text::new("menu"))
566 .body(Container::<Msg>::new());
567 assert_eq!(Widget::<Msg>::anim_target(&open), Some(1.0));
568 assert_eq!(Widget::<Msg>::overlay_dismiss(&open), Some(Msg::Close));
569 }
570
571 #[test]
572 fn right_drawer_uses_right_placement() {
573 let d = Drawer::new(true)
574 .right()
575 .panel(Text::new("menu"))
576 .body(Container::<Msg>::new());
577 assert!(matches!(
578 Widget::<Msg>::overlay(&d),
579 Some((_, Placement::Right))
580 ));
581 }
582
583 #[test]
584 fn closed_drawer_draws_no_scrim() {
585 let drawer = Drawer::new(false)
586 .on_dismiss(Msg::Close)
587 .panel(Text::new("menu"))
588 .body(Container::<Msg>::new());
589 let ui = build_ui(
590 &drawer,
591 Size::new(500.0, 400.0),
592 &Runtime::default(),
593 &Theme::default(),
594 );
595 let scrim =
596 ui.scene().primitives().iter().any(
597 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 500.0),
598 );
599 assert!(!scrim, "a closed drawer paints no scrim");
600 }
601
602 #[test]
603 fn the_panel_is_as_wide_as_it_was_told_to_be() {
604 let panel_width = |drawer: &dyn Widget<Msg>| {
607 let ui = build_ui(
608 drawer,
609 Size::new(900.0, 400.0),
610 &Runtime::default(),
611 &Theme::default(),
612 );
613 ui.scene()
614 .primitives()
615 .iter()
616 .find_map(|p| match p {
617 frus_core::Primitive::Rect { rect, .. } if rect.height >= 399.0 => {
618 Some(rect.width)
619 }
620 _ => None,
621 })
622 .expect("the panel is a full-height rectangle")
623 };
624 let default = Drawer::new(true)
625 .permanent(true)
626 .panel(Text::new("menu"))
627 .body(Container::<Msg>::new());
628 assert_eq!(panel_width(&default), DRAWER_WIDTH);
629 assert_eq!(DRAWER_WIDTH, 304.0, "the reference's figure");
630
631 let narrow = Drawer::new(true)
632 .permanent(true)
633 .width(96.0)
634 .panel(Text::new("menu"))
635 .body(Container::<Msg>::new());
636 assert_eq!(panel_width(&narrow), 96.0);
637 }
638
639 #[test]
640 fn open_drawer_draws_scrim_and_full_height_panel() {
641 let drawer = Drawer::new(true)
642 .on_dismiss(Msg::Close)
643 .panel(Text::new("menu"))
644 .body(Container::<Msg>::new());
645 let ui = build_ui(
646 &drawer,
647 Size::new(500.0, 400.0),
648 &Runtime::default(),
649 &Theme::default(),
650 );
651 let scrim =
652 ui.scene().primitives().iter().any(
653 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 500.0),
654 );
655 assert!(scrim, "the scrim must cover the window");
656 let panel = ui.scene().primitives().iter().any(|p| {
657 matches!(p, frus_core::Primitive::Rect { rect, .. }
658 if (rect.width - DRAWER_WIDTH).abs() < 1.0 && rect.height >= 399.0)
659 });
660 assert!(panel, "the panel must expand to the full height");
661 }
662
663 #[test]
664 fn mid_animation_slides_panel_and_fades_scrim() {
665 let drawer = Drawer::new(true)
666 .on_dismiss(Msg::Close)
667 .panel(Text::new("menu"))
668 .body(Container::<Msg>::new());
669 let mut rt = Runtime::default();
670 rt.set_value(crate::WidgetId::ROOT, 0.5);
671 let ui = build_ui(&drawer, Size::new(500.0, 400.0), &rt, &Theme::default());
672 let panel_edge = ui.scene().primitives().iter().find_map(|p| match p {
673 frus_core::Primitive::Rect { rect, .. } if (rect.width - DRAWER_WIDTH).abs() < 1.0 => {
674 Some(rect.x + rect.width)
675 }
676 _ => None,
677 });
678 let edge = panel_edge.expect("the drawer's panel must be present");
679 let expected = crate::spring_ease(0.5) * DRAWER_WIDTH;
682 assert!(
683 (edge - expected).abs() < 2.0,
684 "right edge expected ≈ {expected}, got {edge}"
685 );
686 }
687
688 #[test]
689 fn permanent_drawer_docks_panel_beside_body_without_scrim() {
690 let drawer = Drawer::new(false) .permanent(true)
692 .panel(Text::new("menu"))
693 .body(Container::<Msg>::new());
694 assert_eq!(Widget::<Msg>::anim_target(&drawer), None);
696 assert!(Widget::<Msg>::overlay(&drawer).is_none());
697 assert_eq!(
699 Widget::<Msg>::style(&drawer).flex_direction,
700 FlexDirection::Row
701 );
702 assert_eq!(Widget::<Msg>::children(&drawer).len(), 2);
703
704 let ui = build_ui(
705 &drawer,
706 Size::new(900.0, 400.0),
707 &Runtime::default(),
708 &Theme::default(),
709 );
710 let scrim =
712 ui.scene().primitives().iter().any(
713 |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 900.0),
714 );
715 assert!(!scrim, "a permanent drawer paints no scrim");
716 let docked = ui.scene().primitives().iter().any(|p| {
718 matches!(p, frus_core::Primitive::Rect { rect, .. }
719 if (rect.width - DRAWER_WIDTH).abs() < 1.0 && rect.x < 1.0 && rect.height >= 399.0)
720 });
721 assert!(docked, "the panel must be docked left, full-height");
722 }
723
724 #[test]
725 fn permanent_right_docks_panel_on_the_right() {
726 let drawer = Drawer::new(false)
727 .permanent(true)
728 .right()
729 .panel(Text::new("menu"))
730 .body(Container::<Msg>::new());
731 assert_eq!(Widget::<Msg>::children(&drawer).len(), 2);
733 let ui = build_ui(
734 &drawer,
735 Size::new(900.0, 400.0),
736 &Runtime::default(),
737 &Theme::default(),
738 );
739 let on_right = ui.scene().primitives().iter().any(|p| {
741 matches!(p, frus_core::Primitive::Rect { rect, .. }
742 if (rect.width - DRAWER_WIDTH).abs() < 1.0 && (rect.x + rect.width - 900.0).abs() < 1.0)
743 });
744 assert!(on_right, "the panel must be docked on the right");
745 }
746
747 fn panel_rect(drawer: &dyn Widget<Msg>, theme: &Theme) -> frus_core::Primitive {
749 let ui = build_ui(drawer, Size::new(900.0, 400.0), &Runtime::default(), theme);
750 ui.scene()
751 .primitives()
752 .iter()
753 .find(|p| {
754 matches!(p, frus_core::Primitive::Rect { rect, radius, blur, .. }
755 if (rect.height - 400.0).abs() < 1.0
756 && *blur == 0.0
757 && *radius != frus_core::BorderRadius::ZERO)
758 })
759 .cloned()
760 .expect("the panel is a full-height rounded rectangle")
761 }
762
763 #[test]
771 fn a_panel_takes_a_whole_shape() {
772 let theme = Theme::default();
773 let corners = |drawer: &dyn Widget<Msg>, theme: &Theme| {
774 let frus_core::Primitive::Rect { radius, .. } = panel_rect(drawer, theme) else {
775 panic!("a rectangle");
776 };
777 radius
778 };
779
780 let told = Drawer::new(false)
782 .permanent(true)
783 .shape(frus_core::ShapeBorder::rounded(
784 frus_core::BorderRadius::top(9.0),
785 ))
786 .panel(Text::new("menu"))
787 .body(Container::<Msg>::new());
788 assert_eq!(corners(&told, &theme), frus_core::BorderRadius::top(9.0));
789
790 let mut themed = Theme::default();
794 themed.widgets.drawer.shape = Some(frus_core::ShapeBorder::rounded(
795 frus_core::BorderRadius::uniform(3.0),
796 ));
797 let leading = Drawer::new(false)
798 .permanent(true)
799 .panel(Text::new("menu"))
800 .body(Container::<Msg>::new());
801 assert_eq!(
802 corners(&leading, &themed),
803 frus_core::BorderRadius::uniform(3.0)
804 );
805
806 let trailing = Drawer::new(false)
807 .permanent(true)
808 .right()
809 .panel(Text::new("menu"))
810 .body(Container::<Msg>::new());
811 assert_eq!(
812 corners(&trailing, &themed),
813 frus_core::BorderRadius::left(DRAWER_RADIUS),
814 "a leading panel's shape says nothing about a trailing one"
815 );
816
817 themed.widgets.drawer.end_shape = Some(frus_core::ShapeBorder::rounded(
818 frus_core::BorderRadius::uniform(5.0),
819 ));
820 assert_eq!(
821 corners(&trailing, &themed),
822 frus_core::BorderRadius::uniform(5.0)
823 );
824 }
825
826 #[test]
830 fn the_inner_edge_is_rounded_and_the_outer_one_is_not() {
831 let theme = Theme::default();
832 let leading = Drawer::new(false)
833 .permanent(true)
834 .panel(Text::new("menu"))
835 .body(Container::<Msg>::new());
836 let frus_core::Primitive::Rect { radius, .. } = panel_rect(&leading, &theme) else {
837 panic!("a rectangle");
838 };
839 assert_eq!(radius, frus_core::BorderRadius::right(DRAWER_RADIUS));
840 assert_eq!(DRAWER_RADIUS, 16.0, "the reference's figure");
841
842 let trailing = Drawer::new(false)
843 .permanent(true)
844 .right()
845 .panel(Text::new("menu"))
846 .body(Container::<Msg>::new());
847 let frus_core::Primitive::Rect { radius, .. } = panel_rect(&trailing, &theme) else {
848 panic!("a rectangle");
849 };
850 assert_eq!(radius, frus_core::BorderRadius::left(DRAWER_RADIUS));
851 }
852
853 #[test]
859 fn the_inner_edge_follows_the_direction_rather_than_the_side_it_was_asked_for() {
860 let rtl = Theme::default().rtl();
861 let leading = Drawer::new(false)
862 .permanent(true)
863 .panel(Text::new("menu"))
864 .body(Container::<Msg>::new());
865 let frus_core::Primitive::Rect { rect, radius, .. } = panel_rect(&leading, &rtl) else {
866 panic!("a rectangle");
867 };
868 assert!(
870 (rect.x + rect.width - 900.0).abs() < 1.0,
871 "a leading panel is on the right in RTL, got x={}",
872 rect.x
873 );
874 assert_eq!(radius, frus_core::BorderRadius::left(DRAWER_RADIUS));
876
877 let ui = build_ui(&leading, Size::new(900.0, 400.0), &Runtime::default(), &rtl);
879 let hairline = ui
880 .scene()
881 .primitives()
882 .iter()
883 .find_map(|p| match p {
884 frus_core::Primitive::Rect { rect, .. } if rect.width <= 1.5 => Some(rect.x),
885 _ => None,
886 })
887 .expect("a hairline");
888 assert!(
889 (hairline - rect.x).abs() < 1.5,
890 "the hairline belongs on the inner edge (x≈{}), got {hairline}",
891 rect.x
892 );
893 }
894
895 #[test]
897 fn the_panels_colours_are_the_instances_then_the_themes() {
898 let mut theme = Theme::default();
899 let fill = |drawer: &dyn Widget<Msg>, theme: &Theme| match panel_rect(drawer, theme) {
900 frus_core::Primitive::Rect { color, .. } => color,
901 _ => unreachable!(),
902 };
903 let plain = || {
904 Drawer::new(false)
905 .permanent(true)
906 .panel(Text::new("menu"))
907 .body(Container::<Msg>::new())
908 };
909 assert_eq!(
910 fill(&plain(), &theme),
911 theme.scheme.surface_container_low,
912 "the ladder's low rung"
913 );
914
915 theme.widgets.drawer.background_color = Some(Color::rgb(0.0, 1.0, 0.0));
916 assert_eq!(
917 fill(&plain(), &theme),
918 Color::rgb(0.0, 1.0, 0.0),
919 "the theme"
920 );
921
922 let own = plain().background_color(Color::rgb(0.0, 0.0, 1.0));
924 assert_eq!(
925 fill(&own, &theme),
926 Color::rgb(0.0, 0.0, 1.0),
927 "the instance wins"
928 );
929 }
930
931 #[test]
934 fn a_zero_border_removes_the_hairline() {
935 let bare = Drawer::new(false)
936 .permanent(true)
937 .border_width(0.0)
938 .panel(Text::new("menu"))
939 .body(Container::<Msg>::new());
940 let ui = build_ui(
941 &bare,
942 Size::new(900.0, 400.0),
943 &Runtime::default(),
944 &Theme::default(),
945 );
946 let slivers = ui
947 .scene()
948 .primitives()
949 .iter()
950 .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width <= 1.5))
951 .count();
952 assert_eq!(slivers, 0, "no rule at all");
953 }
954
955 #[test]
958 fn the_scrim_is_the_drawers_to_colour() {
959 let window = |theme: &Theme, drawer: &dyn Widget<Msg>| {
960 let ui = build_ui(drawer, Size::new(500.0, 400.0), &Runtime::default(), theme);
961 ui.scene()
962 .primitives()
963 .iter()
964 .find_map(|p| match p {
965 frus_core::Primitive::Rect { rect, color, .. } if rect.width >= 500.0 => {
966 Some(*color)
967 }
968 _ => None,
969 })
970 .expect("the scrim covers the window")
971 };
972 let theme = Theme::default();
973 let plain = Drawer::new(true)
974 .on_dismiss(Msg::Close)
975 .panel(Text::new("menu"))
976 .body(Container::<Msg>::new());
977 assert_eq!(window(&theme, &plain), theme.scheme.scrim.with_alpha(0.5));
978
979 let own = Drawer::new(true)
980 .on_dismiss(Msg::Close)
981 .scrim_color(Color::rgba(1.0, 0.0, 0.0, 0.25))
982 .panel(Text::new("menu"))
983 .body(Container::<Msg>::new());
984 assert_eq!(window(&theme, &own), Color::rgba(1.0, 0.0, 0.0, 0.25));
985
986 let clear = Drawer::new(true)
988 .on_dismiss(Msg::Close)
989 .scrim_color(Color::TRANSPARENT)
990 .panel(Text::new("menu"))
991 .body(Container::<Msg>::new());
992 assert_eq!(window(&theme, &clear).a, 0.0);
993 }
994
995 #[test]
998 fn the_scrim_fades_with_the_slide() {
999 let drawer = Drawer::new(true)
1000 .on_dismiss(Msg::Close)
1001 .scrim_color(Color::rgba(0.0, 0.0, 0.0, 0.8))
1002 .panel(Text::new("menu"))
1003 .body(Container::<Msg>::new());
1004 let mut rt = Runtime::default();
1005 rt.set_value(crate::WidgetId::ROOT, 0.5);
1006 let ui = build_ui(&drawer, Size::new(500.0, 400.0), &rt, &Theme::default());
1007 let alpha = ui
1008 .scene()
1009 .primitives()
1010 .iter()
1011 .find_map(|p| match p {
1012 frus_core::Primitive::Rect { rect, color, .. } if rect.width >= 500.0 => {
1013 Some(color.a)
1014 }
1015 _ => None,
1016 })
1017 .expect("a scrim");
1018 let expected = 0.8 * crate::spring_ease(0.5);
1019 assert!(
1020 (alpha - expected).abs() < 0.02,
1021 "expected ≈ {expected}, got {alpha}"
1022 );
1023 }
1024
1025 #[test]
1028 fn elevation_casts_the_shadow_along_the_inner_edge() {
1029 let shadows = |drawer: &dyn Widget<Msg>| {
1030 let ui = build_ui(
1031 drawer,
1032 Size::new(900.0, 400.0),
1033 &Runtime::default(),
1034 &Theme::default(),
1035 );
1036 ui.scene()
1037 .primitives()
1038 .iter()
1039 .filter_map(|p| match p {
1040 frus_core::Primitive::Rect { rect, blur, .. } if *blur > 0.0 => Some(rect.x),
1041 _ => None,
1042 })
1043 .collect::<Vec<_>>()
1044 };
1045 let flat = Drawer::new(false)
1046 .permanent(true)
1047 .panel(Text::new("menu"))
1048 .body(Container::<Msg>::new());
1049 assert!(shadows(&flat).is_empty(), "no shadow by default");
1050
1051 let height_only = Drawer::new(false)
1055 .permanent(true)
1056 .elevation(3.0)
1057 .panel(Text::new("menu"))
1058 .body(Container::<Msg>::new());
1059 assert!(shadows(&height_only).is_empty(), "no colour, no shadow");
1060
1061 let lifted = Drawer::new(false)
1062 .permanent(true)
1063 .elevation(3.0)
1064 .shadow_color(Color::rgba(0.0, 0.0, 0.0, 0.3))
1065 .panel(Text::new("menu"))
1066 .body(Container::<Msg>::new());
1067 let cast = shadows(&lifted);
1068 assert_eq!(cast.len(), 1, "one shadow");
1069 let blur = 3.0 * 4.0 + 8.0;
1072 assert!(
1073 (cast[0] - (6.0 - blur)).abs() < 0.5,
1074 "expected ≈ {}, got {}",
1075 6.0 - blur,
1076 cast[0]
1077 );
1078 }
1079
1080 #[test]
1085 fn a_builder_after_body_is_not_a_no_op() {
1086 let theme = Theme::default();
1087 let measured = |drawer: &dyn Widget<Msg>| match panel_rect(drawer, &theme) {
1088 frus_core::Primitive::Rect { rect, color, .. } => (rect.width, color),
1089 _ => unreachable!(),
1090 };
1091 let before = Drawer::new(false)
1092 .permanent(true)
1093 .width(96.0)
1094 .background_color(Color::rgb(0.0, 0.0, 1.0))
1095 .panel(Text::new("menu"))
1096 .body(Container::<Msg>::new());
1097 let after = Drawer::new(false)
1098 .permanent(true)
1099 .panel(Text::new("menu"))
1100 .body(Container::<Msg>::new())
1101 .width(96.0)
1102 .background_color(Color::rgb(0.0, 0.0, 1.0));
1103 assert_eq!(measured(&before), measured(&after));
1104 assert_eq!(measured(&after), (96.0, Color::rgb(0.0, 0.0, 1.0)));
1105 }
1106}