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            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            // `navigation_drawer.dart:740` — a panel off the page, on the low rung.
230            .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        // The hairline on the inner edge, drawn as its own sliver rather than as a border
235        // round the whole shape: the other three edges are flush against the window, and a
236        // rule down them would be a line against nothing. It stops short of the corners
237        // for the same reason — a straight sliver crossing a rounded corner sticks out
238        // past the shape it is meant to edge.
239        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
270/// A retractable side drawer: a background body + a panel, modal or docked.
271pub struct Drawer<Msg = crate::callback::Callback> {
272    open: bool,
273    right: bool,
274    permanent: bool,
275    on_dismiss: Option<Msg>,
276    /// The drawer's content, supplied by the caller (before `DrawerPanel` wrapping).
277    panel_content: Cell<Option<Box<dyn Widget<Msg>>>>,
278    /// The background content, likewise.
279    body_content: Cell<Option<Box<dyn Widget<Msg>>>>,
280    /// The panel's width; `None` = [`DRAWER_WIDTH`].
281    width: Option<f32>,
282    /// Everything else the panel paints; each `None` = the theme's, then the default.
283    style: PanelStyle,
284    /// The scrim's colour behind a modal panel, alpha included; `None` = the scheme's.
285    scrim_color: Option<Color>,
286    /// The assembled tree, built once, the first time the walk asks for it.
287    assembled: OnceCell<Assembled<Msg>>,
288}
289
290/// What the two pieces the caller handed over become, once every builder has had its
291/// say: the row (or the lone body) in the flow, and the panel that floats.
292struct Assembled<Msg> {
293    /// Children in the flow: `[body]` (modal) or `[panel, body]` (permanent).
294    children: Vec<Box<dyn Widget<Msg>>>,
295    /// The modal panel (non-permanent mode), wrapped, ready for the overlay.
296    modal_panel: Option<Box<dyn Widget<Msg>>>,
297}
298
299impl<Msg: Clone + 'static> Drawer<Msg> {
300    /// Creates a drawer; `open` says whether it is expanded (ignored when permanent).
301    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    /// Overrides the panel's width, in logical pixels. Defaults to [`DRAWER_WIDTH`].
317    ///
318    /// A narrow drawer beside a wide one is a layout decision, not a constant: an
319    /// icon-only rail, a settings panel that wants half the window. The default is what
320    /// a drawer takes when nobody says otherwise, and nothing more than that.
321    pub fn width(mut self, width: f32) -> Self {
322        self.width = Some(width);
323        self
324    }
325
326    /// The panel's fill. Defaults to the theme's `drawer.background_color`, then to the
327    /// theme's surface.
328    pub fn background_color(mut self, color: Color) -> Self {
329        self.style.background_color = Some(color);
330        self
331    }
332
333    /// The hairline on the panel's **inner** edge — the one facing the content.
334    pub fn border_color(mut self, color: Color) -> Self {
335        self.style.border_color = Some(color);
336        self
337    }
338
339    /// That hairline's thickness. `0.0` removes it, which is what a drawer told apart
340    /// from its body by colour or by a shadow wants.
341    pub fn border_width(mut self, width: f32) -> Self {
342        self.style.border_width = Some(width);
343        self
344    }
345
346    /// The rounding of the inner edge's two corners; the outer edge stays square against
347    /// the window. Defaults to [`DRAWER_RADIUS`], and `0.0` squares it off.
348    ///
349    /// Which edge is the inner one is decided **at paint time** from the direction in
350    /// force, not here: a leading drawer sits on the left of the screen in English and on
351    /// the right in Arabic, and the rounding follows it across.
352    pub fn radius(mut self, radius: f32) -> Self {
353        self.style.radius = Some(radius);
354        self
355    }
356
357    /// **What shape the panel is** — the reference's `shape` (`drawer.dart:266`), over
358    /// the inner edge [`radius`](Self::radius) rounds.
359    ///
360    /// A shape is **concrete**: it says *left* and *right*, not *start* and *end*, so a
361    /// caller naming one has taken the direction question on themselves.
362    /// [`BorderRadiusDirectional`](frus_core::BorderRadiusDirectional) is how to answer it
363    /// — `ShapeBorder::rounded(BorderRadiusDirectional::end(16.0).resolve(theme.direction))`
364    /// is exactly what the framework's own default is.
365    #[must_use]
366    pub fn shape(mut self, shape: ShapeBorder) -> Self {
367        self.style.shape = Some(shape);
368        self
369    }
370
371    /// How far off the surface the panel sits. `0.0` — the default — casts no shadow, and
372    /// neither does a height without a [`shadow_color`](Self::shadow_color).
373    ///
374    /// The drop is sideways, along the inner edge: a shadow cast below a panel as tall as
375    /// the window falls outside it and is never seen.
376    pub fn elevation(mut self, elevation: f32) -> Self {
377        self.style.elevation = Some(elevation);
378        self
379    }
380
381    /// The colour of the shadow an elevated panel casts, **alpha included**. Transparent
382    /// by default, as the reference's Material 3 drawer is (its defaults, line 795).
383    pub fn shadow_color(mut self, color: Color) -> Self {
384        self.style.shadow_color = Some(color);
385        self
386    }
387
388    /// The scrim behind a **modal** panel, **alpha included** — a scrim's transparency
389    /// is the colour, so an opaque value here hides the body entirely and
390    /// [`Color::TRANSPARENT`] darkens nothing at all.
391    ///
392    /// Ignored by a permanent drawer, which has no scrim to colour.
393    pub fn scrim_color(mut self, color: Color) -> Self {
394        self.scrim_color = Some(color);
395        self
396    }
397
398    /// Message emitted on a click on the scrim (outside the panel) — to close it.
399    pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
400        self.on_dismiss = Some(message.into());
401        self
402    }
403
404    /// Docks the drawer to the **right** edge (left by default).
405    pub fn right(mut self) -> Self {
406        self.right = true;
407        self
408    }
409
410    /// Makes the drawer **permanent** (docked in the flow, no scrim) when
411    /// `permanent` is true — typically at the `Expanded` breakpoint.
412    pub fn permanent(mut self, permanent: bool) -> Self {
413        self.permanent = permanent;
414        self
415    }
416
417    /// Sets the **drawer's content**, usually the navigation.
418    pub fn panel(self, content: impl Widget<Msg> + 'static) -> Self {
419        self.panel_content.set(Some(Box::new(content)));
420        self
421    }
422
423    /// Sets the **background body**: the content that stays visible behind a modal
424    /// panel, or beside a docked one.
425    ///
426    /// It used to *finalise* the drawer, wrapping the panel there and then — which
427    /// quietly made every builder called after it a no-op, since the panel it would have
428    /// changed already existed. The assembly now happens the first time the walk asks
429    /// for the children, so the builders can come in any order.
430    pub fn body(self, body: impl Widget<Msg> + 'static) -> Self {
431        self.body_content.set(Some(Box::new(body)));
432        self
433    }
434
435    /// The tree, assembled on first use from the two pieces the caller handed over.
436    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                // A drawer with no body: the panel alone, docked or floating. Nothing to
448                // put in the flow, and nothing to fill the window with either.
449                return Assembled {
450                    children: Vec::new(),
451                    modal_panel: panel.filter(|_| !self.permanent),
452                };
453            };
454
455            if self.permanent {
456                // Docked in the flow: a `[panel, body]` row (or the reverse on the right).
457                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                // Modal: only the body is in the flow; the panel goes to the overlay.
469                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    /// It asks to **fill the width it is offered** rather than declaring one — see
480    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
481    /// width, which a parent that shrink-wraps does not have yet.
482    fn style(&self) -> Style {
483        Style {
484            height: Dimension::Percent(1.0),
485            // Permanent: a row (panel + body side by side). Modal: the body fills
486            // on its own (the panel floats as an overlay).
487            flex_direction: if self.permanent {
488                FlexDirection::Row
489            } else {
490                FlexDirection::Column
491            },
492            ..Default::default()
493        }
494    }
495
496    /// The width it was **offered**, not the width its parent came out at.
497    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        // Modal mode only: it is the animated **progress** (`anim_target`)
513        // that decides both the display and the slide.
514        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        // No animation when permanent (it is always shown). Otherwise the `0↔1`
535        // open target, interpolated by the runtime (slide + fade).
536        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        // The default is the reference's 304, and it is a default: an application that
605        // wants a narrow panel says so, and gets exactly that.
606        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        // The slide follows the spring curve: at linear t=0.5, the right edge sits
680        // at `spring_ease(0.5)·width`, already well advanced.
681        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) // `open` is ignored when permanent
691            .permanent(true)
692            .panel(Text::new("menu"))
693            .body(Container::<Msg>::new());
694        // No animation and no overlay: the panel is in the flow.
695        assert_eq!(Widget::<Msg>::anim_target(&drawer), None);
696        assert!(Widget::<Msg>::overlay(&drawer).is_none());
697        // A [panel, body] row.
698        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        // No full-screen scrim (the panel only covers its own width).
711        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        // The docked panel is present, full-height, on the left (x ≈ 0).
717        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        // A [body, panel] order for docking on the right.
732        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        // A full-height panel whose right edge touches the window (x+w ≈ 900).
740        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    /// The panel's rectangle: the full-height one as wide as the drawer.
748    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    /// **A panel takes a whole shape**, which it could not: it had a single `f32` where the
764    /// reference has a `ShapeBorder`, and a theme could say nothing else at all.
765    ///
766    /// A shape is **concrete** — left and right, not start and end — so a caller naming
767    /// one has taken the mirroring on themselves, and
768    /// [`BorderRadiusDirectional`](frus_core::BorderRadiusDirectional) is how they answer
769    /// it. That is exactly how the framework's own default is now written.
770    #[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        // A caller's own shape, whatever side the panel is on.
781        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        // A theme's, on the panel it names — and **not** on the other one, which has an
791        // entry of its own and falls back to the framework rather than to a shape rounded
792        // on the wrong edge.
793        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    /// The inner edge is rounded and the outer one is square. A panel docked against the
827    /// window rounds the corners that face the content; rounding the pair against the
828    /// window edge would cut two notches out of the screen.
829    #[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    /// **The bug this milestone found.** A leading drawer is on the left in English and
854    /// on the **right** in Arabic — the walk mirrors the whole frame — so its inner
855    /// edge changes sides with it. The panel used to rule its hairline and round its
856    /// corners from `right`, the side it was *asked* for, and in RTL that put both down
857    /// the window's own edge.
858    #[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        // Mirrored: a leading panel sits against the *right* of the window…
869        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        // …so the edge facing the content is its left one.
875        assert_eq!(radius, frus_core::BorderRadius::left(DRAWER_RADIUS));
876
877        // And the hairline is on that same edge, not on the window's.
878        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    /// The colours resolve **instance, then theme, then the scheme's role**.
896    #[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        // Deliberately *after* `body()`: see `a_builder_after_body_is_not_a_no_op`.
923        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    /// A hairline of nothing is no hairline: a drawer told apart from its body by colour
932    /// or by a shadow does not want a rule as well.
933    #[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    /// The scrim is the drawer's to colour — alpha included, since a scrim's
956    /// transparency *is* the colour.
957    #[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        // Transparent is an answer: an overlay that darkens nothing.
987        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    /// The scrim fades **with the slide**: at half the animation it is half painted,
996    /// whatever colour it was given.
997    #[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    /// Elevation casts a shadow **sideways**, along the inner edge: one dropped below a
1026    /// panel as tall as the window falls outside it and is never seen.
1027    #[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        // **A height alone casts nothing**: the reference's Material 3
1052        // drawer keeps its elevation and makes the shadow transparent (line 795); this one drew a
1053        // black shadow at 30 % for any height it was given.
1054        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        // A leading panel is lifted towards the content: the shadow's envelope starts to
1070        // the right of the panel's own left edge, offset by the depth.
1071        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    /// **The second bug this milestone found.** `body()` used to finalise the drawer,
1081    /// wrapping the panel there and then — so a builder that came after it changed a
1082    /// field nobody would read again, and did nothing at all without saying so. Written
1083    /// in the order a caller reaches for, the panel is 96 px wide and blue either way.
1084    #[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}