Skip to main content

frus_widgets/
drawer.rs

1//! [`Drawer`]: a retractable **side drawer** — the 3rd tier of Material
2//! navigation, alongside `NavigationRail` (the rail) and `BottomBar` (the bar).
3//!
4//! Two modes:
5//! - **modal** (the default): the body stays visible behind; when the drawer is
6//!   open, a full-height panel slides in from an edge (left or right) over it,
7//!   with a scrim that closes it on click. Opening is animated automatically
8//!   (see milestone 46).
9//! - **permanent** ([`Drawer::permanent`]): the panel is **docked** in the flow,
10//!   always visible beside the body (no scrim). Typically enabled at the
11//!   `Expanded` breakpoint.
12//!
13//! ```ignore
14//! Drawer::new(app.menu_open)
15//!     .on_dismiss(Msg::CloseMenu)
16//!     .permanent(class == SizeClass::Expanded)
17//!     .panel(nav_list)   // the drawer's content
18//!     .body(main_screen) // the background content (always visible)
19//! ```
20//!
21//! Everything it paints is a **default**, not a rule: the fill, the hairline on the
22//! inner edge and its thickness, the rounding of that edge, how far off the surface the
23//! panel sits and the colour of the shadow that casts, and the scrim behind a modal one —
24//! each resolved instance, then [`DrawerTheme`](crate::widgettheme::DrawerTheme), then the
25//! scheme's role.
26//!
27//! ```ignore
28//! Drawer::new(open)
29//!     .background_color(theme.scheme.surface_container_low)
30//!     .border_width(0.0)          // told apart by colour, not by a rule
31//!     .elevation(2.0)             // …or by a shadow along its inner edge,
32//!     .shadow_color(theme.scheme.shadow.with_alpha(0.3)) // in a colour named for it
33//!     .radius(0.0)                // …or squared off entirely
34//!     .scrim_color(Color::TRANSPARENT) // an overlay that darkens nothing
35//! ```
36//!
37//! Which edge is the *inner* one is decided when the panel paints, not when it is
38//! built: a leading drawer sits on the left of the screen in English and on the right in
39//! Arabic, and the rounding and the hairline follow it across.
40
41use 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
54/// The width of a side drawer, in logical pixels, when the caller has not set one —
55/// the reference's own figure, and wide enough for a navigation label not to wrap.
56///
57/// It is a default, not a constraint: see [`Drawer::width`].
58pub const DRAWER_WIDTH: f32 = 304.0;
59
60/// The rounding of the panel's **inner** edge — the reference's figure, and the shape
61/// its own source calls *shown in the spec*. The outer edge stays square: a panel docked
62/// against the window rounds the corners that face the content, not the ones that face
63/// nothing.
64///
65/// A default, not a constraint: see [`Drawer::radius`].
66pub const DRAWER_RADIUS: f32 = 16.0;
67
68/// The drawer's inner panel: full height, a themed background, and its **inner** edge
69/// rounded and ruled — the edge that faces the content.
70///
71/// Which edge that is, the panel does not know until it paints. `end` says which side it
72/// was docked to in **logical** terms (leading or trailing), and the direction in force
73/// turns that into a side of the screen: a leading drawer is on the left in LTR and on
74/// the right in RTL, and its inner edge follows.
75struct DrawerPanel<Msg> {
76    children: Vec<Box<dyn Widget<Msg>>>,
77    /// Docked to the **trailing** edge ([`Drawer::right`]) rather than the leading one.
78    end: bool,
79    /// The panel's width; `None` = the theme's, then [`DRAWER_WIDTH`].
80    width: Option<f32>,
81    style: PanelStyle,
82}
83
84/// Everything the panel paints that a caller can name, gathered so [`Drawer`]'s builders
85/// have one place to put it. Every field is `None` for *ask the theme, then the default*.
86#[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    /// What shape the panel is, over the theme's; `None` falls to the radius below it.
93    shape: Option<ShapeBorder>,
94    elevation: Option<f32>,
95    shadow_color: Option<Color>,
96}
97
98impl<Msg> DrawerPanel<Msg> {
99    /// The width actually used: what the caller said, then the theme, then ours.
100    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    /// Whether the panel has landed on the **right** of the screen.
107    ///
108    /// The trailing edge is the right one in LTR and the left one in RTL, and a panel
109    /// that guessed would rule its hairline down the window's own edge in Arabic.
110    fn docked_right(&self, theme: &Theme) -> bool {
111        self.end != (theme.direction == TextDirection::Rtl)
112    }
113
114    /// **The panel's inner edge, rounded by `r`** — the edge facing the page.
115    ///
116    /// Written the reference's way (`drawer.dart:801`, `:810`): a leading panel rounds its
117    /// **end** corners and a trailing one its **start** corners, and which wall that is
118    /// gets decided by [`BorderRadiusDirectional::resolve`]. The two questions — which
119    /// side the panel is docked on, and which way the text runs — used to be folded into
120    /// one `docked_right` and answered with a hand-picked `left` or `right`. They are the
121    /// same answer, said in the reference's words rather than worked out again here.
122    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    /// **What shape the panel is**: its own word, then the theme's — the trailing panel's
131    /// own entry, never the leading one's — then the theme's plain radius on the inner
132    /// edge, then the framework's.
133    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    /// The corners that shape resolves to — what the fill, the shadow and the hairline's
151    /// inset are all worked out from. A shape with no rounded form leaves the panel
152    /// square, as it does everywhere else since milestone 451.
153    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            // A panel wider than the window **overflows** rather than shrinking, unlike
165            // the reference's, whose width is enforced against the parent's constraints.
166            // `max_width: Percent(1.0)` does not fix it: the overlay layer the panel is
167            // laid out in has no definite width to take a percentage of. Recorded in
168            // milestone 307 rather than patched with something that does nothing.
169            width: Dimension::Length(self.resolved_width(None)),
170            // The height expands to the whole window (side placement) or to the
171            // row's height (permanent mode).
172            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        // Transparent unless named, as the reference's Material 3 drawer is
199        // (its drawer defaults, line 795): a height alone is no shadow.
200        let shadow = self
201            .style
202            .shadow_color
203            .or(theme.widgets.drawer.shadow_color)
204            .unwrap_or(Color::TRANSPARENT);
205
206        // The shadow, when the caller has lifted the panel off the surface and named a
207        // colour for it. The drop is **sideways** rather than down: a panel is lifted along
208        // its inner edge, and a shadow cast below a full-height panel falls outside the
209        // window entirely.
210        if depth > 0.0 && shadow.a > 0.0 {
211            // The reference's shadows for this height (milestone 606).
212            frus_core::paint_elevation(scene, bounds, radius, depth, shadow.fade(o));
213        }
214
215        let fill = self
216            .style
217            .background_color
218            // `navigation_drawer.dart:740` — a panel off the page, on the low rung.
219            .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        // The hairline on the inner edge, drawn as its own sliver rather than as a border
224        // round the whole shape: the other three edges are flush against the window, and a
225        // rule down them would be a line against nothing. It stops short of the corners
226        // for the same reason — a straight sliver crossing a rounded corner sticks out
227        // past the shape it is meant to edge.
228        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
259/// A retractable side drawer: a background body + a panel, modal or docked.
260pub struct Drawer<Msg = crate::callback::Callback> {
261    open: bool,
262    right: bool,
263    permanent: bool,
264    on_dismiss: Option<Msg>,
265    /// The drawer's content, supplied by the caller (before `DrawerPanel` wrapping).
266    panel_content: Cell<Option<Box<dyn Widget<Msg>>>>,
267    /// The background content, likewise.
268    body_content: Cell<Option<Box<dyn Widget<Msg>>>>,
269    /// The panel's width; `None` = [`DRAWER_WIDTH`].
270    width: Option<f32>,
271    /// Everything else the panel paints; each `None` = the theme's, then the default.
272    style: PanelStyle,
273    /// The scrim's colour behind a modal panel, alpha included; `None` = the scheme's.
274    scrim_color: Option<Color>,
275    /// The assembled tree, built once, the first time the walk asks for it.
276    assembled: OnceCell<Assembled<Msg>>,
277}
278
279/// What the two pieces the caller handed over become, once every builder has had its
280/// say: the row (or the lone body) in the flow, and the panel that floats.
281struct Assembled<Msg> {
282    /// Children in the flow: `[body]` (modal) or `[panel, body]` (permanent).
283    children: Vec<Box<dyn Widget<Msg>>>,
284    /// The modal panel (non-permanent mode), wrapped, ready for the overlay.
285    modal_panel: Option<Box<dyn Widget<Msg>>>,
286}
287
288impl<Msg: Clone + 'static> Drawer<Msg> {
289    /// Creates a drawer; `open` says whether it is expanded (ignored when permanent).
290    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    /// Overrides the panel's width, in logical pixels. Defaults to [`DRAWER_WIDTH`].
306    ///
307    /// A narrow drawer beside a wide one is a layout decision, not a constant: an
308    /// icon-only rail, a settings panel that wants half the window. The default is what
309    /// a drawer takes when nobody says otherwise, and nothing more than that.
310    pub fn width(mut self, width: f32) -> Self {
311        self.width = Some(width);
312        self
313    }
314
315    /// The panel's fill. Defaults to the theme's `drawer.background_color`, then to the
316    /// theme's surface.
317    pub fn background_color(mut self, color: Color) -> Self {
318        self.style.background_color = Some(color);
319        self
320    }
321
322    /// The hairline on the panel's **inner** edge — the one facing the content.
323    pub fn border_color(mut self, color: Color) -> Self {
324        self.style.border_color = Some(color);
325        self
326    }
327
328    /// That hairline's thickness. `0.0` removes it, which is what a drawer told apart
329    /// from its body by colour or by a shadow wants.
330    pub fn border_width(mut self, width: f32) -> Self {
331        self.style.border_width = Some(width);
332        self
333    }
334
335    /// The rounding of the inner edge's two corners; the outer edge stays square against
336    /// the window. Defaults to [`DRAWER_RADIUS`], and `0.0` squares it off.
337    ///
338    /// Which edge is the inner one is decided **at paint time** from the direction in
339    /// force, not here: a leading drawer sits on the left of the screen in English and on
340    /// the right in Arabic, and the rounding follows it across.
341    pub fn radius(mut self, radius: f32) -> Self {
342        self.style.radius = Some(radius);
343        self
344    }
345
346    /// **What shape the panel is** — the reference's `shape` (`drawer.dart:266`), over
347    /// the inner edge [`radius`](Self::radius) rounds.
348    ///
349    /// A shape is **concrete**: it says *left* and *right*, not *start* and *end*, so a
350    /// caller naming one has taken the direction question on themselves.
351    /// [`BorderRadiusDirectional`](frus_core::BorderRadiusDirectional) is how to answer it
352    /// — `ShapeBorder::rounded(BorderRadiusDirectional::end(16.0).resolve(theme.direction))`
353    /// is exactly what the framework's own default is.
354    #[must_use]
355    pub fn shape(mut self, shape: ShapeBorder) -> Self {
356        self.style.shape = Some(shape);
357        self
358    }
359
360    /// How far off the surface the panel sits. `0.0` — the default — casts no shadow, and
361    /// neither does a height without a [`shadow_color`](Self::shadow_color).
362    ///
363    /// The drop is sideways, along the inner edge: a shadow cast below a panel as tall as
364    /// the window falls outside it and is never seen.
365    pub fn elevation(mut self, elevation: f32) -> Self {
366        self.style.elevation = Some(elevation);
367        self
368    }
369
370    /// The colour of the shadow an elevated panel casts, **alpha included**. Transparent
371    /// by default, as the reference's Material 3 drawer is (its defaults, line 795).
372    pub fn shadow_color(mut self, color: Color) -> Self {
373        self.style.shadow_color = Some(color);
374        self
375    }
376
377    /// The scrim behind a **modal** panel, **alpha included** — a scrim's transparency
378    /// is the colour, so an opaque value here hides the body entirely and
379    /// [`Color::TRANSPARENT`] darkens nothing at all.
380    ///
381    /// Ignored by a permanent drawer, which has no scrim to colour.
382    pub fn scrim_color(mut self, color: Color) -> Self {
383        self.scrim_color = Some(color);
384        self
385    }
386
387    /// Message emitted on a click on the scrim (outside the panel) — to close it.
388    pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
389        self.on_dismiss = Some(message.into());
390        self
391    }
392
393    /// Docks the drawer to the **right** edge (left by default).
394    pub fn right(mut self) -> Self {
395        self.right = true;
396        self
397    }
398
399    /// Makes the drawer **permanent** (docked in the flow, no scrim) when
400    /// `permanent` is true — typically at the `Expanded` breakpoint.
401    pub fn permanent(mut self, permanent: bool) -> Self {
402        self.permanent = permanent;
403        self
404    }
405
406    /// Sets the **drawer's content**, usually the navigation.
407    pub fn panel(self, content: impl Widget<Msg> + 'static) -> Self {
408        self.panel_content.set(Some(Box::new(content)));
409        self
410    }
411
412    /// Sets the **background body**: the content that stays visible behind a modal
413    /// panel, or beside a docked one.
414    ///
415    /// It used to *finalise* the drawer, wrapping the panel there and then — which
416    /// quietly made every builder called after it a no-op, since the panel it would have
417    /// changed already existed. The assembly now happens the first time the walk asks
418    /// for the children, so the builders can come in any order.
419    pub fn body(self, body: impl Widget<Msg> + 'static) -> Self {
420        self.body_content.set(Some(Box::new(body)));
421        self
422    }
423
424    /// The tree, assembled on first use from the two pieces the caller handed over.
425    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                // A drawer with no body: the panel alone, docked or floating. Nothing to
437                // put in the flow, and nothing to fill the window with either.
438                return Assembled {
439                    children: Vec::new(),
440                    modal_panel: panel.filter(|_| !self.permanent),
441                };
442            };
443
444            if self.permanent {
445                // Docked in the flow: a `[panel, body]` row (or the reverse on the right).
446                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                // Modal: only the body is in the flow; the panel goes to the overlay.
458                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    /// It asks to **fill the width it is offered** rather than declaring one — see
469    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
470    /// width, which a parent that shrink-wraps does not have yet.
471    fn style(&self) -> Style {
472        Style {
473            height: Dimension::Percent(1.0),
474            // Permanent: a row (panel + body side by side). Modal: the body fills
475            // on its own (the panel floats as an overlay).
476            flex_direction: if self.permanent {
477                FlexDirection::Row
478            } else {
479                FlexDirection::Column
480            },
481            ..Default::default()
482        }
483    }
484
485    /// The width it was **offered**, not the width its parent came out at.
486    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        // Modal mode only: it is the animated **progress** (`anim_target`)
502        // that decides both the display and the slide.
503        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        // No animation when permanent (it is always shown). Otherwise the `0↔1`
524        // open target, interpolated by the runtime (slide + fade).
525        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        // The default is the reference's 304, and it is a default: an application that
594        // wants a narrow panel says so, and gets exactly that.
595        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        // The slide follows the spring curve: at linear t=0.5, the right edge sits
669        // at `spring_ease(0.5)·width`, already well advanced.
670        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) // `open` is ignored when permanent
680            .permanent(true)
681            .panel(Text::new("menu"))
682            .body(Container::<Msg>::new());
683        // No animation and no overlay: the panel is in the flow.
684        assert_eq!(Widget::<Msg>::anim_target(&drawer), None);
685        assert!(Widget::<Msg>::overlay(&drawer).is_none());
686        // A [panel, body] row.
687        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        // No full-screen scrim (the panel only covers its own width).
700        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        // The docked panel is present, full-height, on the left (x ≈ 0).
706        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        // A [body, panel] order for docking on the right.
721        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        // A full-height panel whose right edge touches the window (x+w ≈ 900).
729        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    /// The panel's rectangle: the full-height one as wide as the drawer.
737    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    /// **A panel takes a whole shape**, which it could not: it had a single `f32` where the
753    /// reference has a `ShapeBorder`, and a theme could say nothing else at all.
754    ///
755    /// A shape is **concrete** — left and right, not start and end — so a caller naming
756    /// one has taken the mirroring on themselves, and
757    /// [`BorderRadiusDirectional`](frus_core::BorderRadiusDirectional) is how they answer
758    /// it. That is exactly how the framework's own default is now written.
759    #[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        // A caller's own shape, whatever side the panel is on.
770        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        // A theme's, on the panel it names — and **not** on the other one, which has an
780        // entry of its own and falls back to the framework rather than to a shape rounded
781        // on the wrong edge.
782        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    /// The inner edge is rounded and the outer one is square. A panel docked against the
816    /// window rounds the corners that face the content; rounding the pair against the
817    /// window edge would cut two notches out of the screen.
818    #[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    /// **The bug this milestone found.** A leading drawer is on the left in English and
843    /// on the **right** in Arabic — the walk mirrors the whole frame — so its inner
844    /// edge changes sides with it. The panel used to rule its hairline and round its
845    /// corners from `right`, the side it was *asked* for, and in RTL that put both down
846    /// the window's own edge.
847    #[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        // Mirrored: a leading panel sits against the *right* of the window…
858        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        // …so the edge facing the content is its left one.
864        assert_eq!(radius, frus_core::BorderRadius::left(DRAWER_RADIUS));
865
866        // And the hairline is on that same edge, not on the window's.
867        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    /// The colours resolve **instance, then theme, then the scheme's role**.
885    #[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        // Deliberately *after* `body()`: see `a_builder_after_body_is_not_a_no_op`.
912        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    /// A hairline of nothing is no hairline: a drawer told apart from its body by colour
921    /// or by a shadow does not want a rule as well.
922    #[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    /// The scrim is the drawer's to colour — alpha included, since a scrim's
945    /// transparency *is* the colour.
946    #[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        // Transparent is an answer: an overlay that darkens nothing.
976        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    /// The scrim fades **with the slide**: at half the animation it is half painted,
985    /// whatever colour it was given.
986    #[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    /// Elevation casts a shadow **sideways**, along the inner edge: one dropped below a
1015    /// panel as tall as the window falls outside it and is never seen.
1016    #[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        // **A height alone casts nothing**: the reference's Material 3
1041        // drawer keeps its elevation and makes the shadow transparent (line 795); this one drew a
1042        // black shadow at 30 % for any height it was given.
1043        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        // It is cast as any lifted surface's is (milestone 606): the widest reaches past
1059        // the panel's inner edge, onto the content beside it.
1060        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    /// **The second bug this milestone found.** `body()` used to finalise the drawer,
1073    /// wrapping the panel there and then — so a builder that came after it changed a
1074    /// field nobody would read again, and did nothing at all without saying so. Written
1075    /// in the order a caller reaches for, the panel is 96 px wide and blue either way.
1076    #[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}