Skip to main content

elegance/
drawer.rs

1//! Drawer — a side-anchored slide-in overlay panel.
2//!
3//! A [`Drawer`] is a full-height panel that slides in from the left or right
4//! edge of the viewport over a dimmed backdrop. The panel is dismissed by
5//! pressing `Esc`, clicking the backdrop, or clicking the built-in close
6//! button. Use one for record inspectors, edit forms, filter sidebars, and
7//! anything else that's too tall for a [`Modal`](crate::Modal) but doesn't
8//! deserve a route of its own.
9//!
10//! ```no_run
11//! # use elegance::{Drawer, DrawerSide};
12//! # let ctx = egui::Context::default();
13//! # let mut open = true;
14//! Drawer::new("inspector", &mut open)
15//!     .side(DrawerSide::Right)
16//!     .width(420.0)
17//!     .title("INC-2187")
18//!     .subtitle("api-west-02 latency spike")
19//!     .show(&ctx, |ui| {
20//!         ui.label("…drawer body, scroll if needed…");
21//!     });
22//! ```
23//!
24//! # Layout inside the body closure
25//!
26//! The panel is full-height. For the common header/scrollable-body/pinned-
27//! footer layout, slice the body's vertical space yourself:
28//!
29//! ```no_run
30//! # use elegance::{Accent, Button, Drawer};
31//! # let ctx = egui::Context::default();
32//! # let mut open = true;
33//! Drawer::new("edit", &mut open).title("Edit member").show(&ctx, |ui| {
34//!     let footer_h = 56.0;
35//!     let body_h = (ui.available_height() - footer_h).max(0.0);
36//!     ui.allocate_ui_with_layout(
37//!         egui::vec2(ui.available_width(), body_h),
38//!         egui::Layout::top_down(egui::Align::Min),
39//!         |ui| {
40//!             egui::ScrollArea::vertical().show(ui, |ui| {
41//!                 ui.label("…form fields…");
42//!             });
43//!         },
44//!     );
45//!     ui.horizontal(|ui| {
46//!         let _ = ui.add(Button::new("Save").accent(Accent::Blue));
47//!         let _ = ui.add(Button::new("Cancel").outline());
48//!     });
49//! });
50//! ```
51//!
52//! For *persistent* (non-overlay) side panels, reach for [`egui::SidePanel`]
53//! directly: it integrates with the surrounding layout so the main content
54//! resizes around it. `Drawer` is for the modal slide-in case.
55
56use std::hash::Hash;
57
58use egui::{
59    accesskit, emath, epaint::Shadow, Align, Area, Color32, Context, CornerRadius, Frame, Id, Key,
60    Layout, Margin, Order, Pos2, Rect, Response, Sense, Stroke, Ui, WidgetInfo, WidgetText,
61    WidgetType,
62};
63
64use crate::{theme::Theme, Button, ButtonSize};
65
66/// Which edge of the viewport the drawer slides in from.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum DrawerSide {
69    /// Slide in from the left edge.
70    Left,
71    /// Slide in from the right edge. The default.
72    Right,
73}
74
75/// A side-anchored slide-in overlay panel.
76///
77/// The `open` flag drives visibility. While it transitions from `false` to
78/// `true` the panel slides in from its anchored edge; the reverse plays in
79/// reverse. Pressing `Esc`, clicking the dimmed backdrop, or clicking the
80/// built-in close "×" button flips it back to `false`.
81#[must_use = "Call `.show(ctx, |ui| { ... })` to render the drawer."]
82pub struct Drawer<'a> {
83    id_salt: Id,
84    open: &'a mut bool,
85    side: DrawerSide,
86    width: f32,
87    title: Option<WidgetText>,
88    subtitle: Option<WidgetText>,
89    close_on_backdrop: bool,
90    close_on_escape: bool,
91}
92
93impl<'a> std::fmt::Debug for Drawer<'a> {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("Drawer")
96            .field("id_salt", &self.id_salt)
97            .field("open", &*self.open)
98            .field("side", &self.side)
99            .field("width", &self.width)
100            .field("title", &self.title.as_ref().map(|t| t.text()))
101            .field("subtitle", &self.subtitle.as_ref().map(|t| t.text()))
102            .field("close_on_backdrop", &self.close_on_backdrop)
103            .field("close_on_escape", &self.close_on_escape)
104            .finish()
105    }
106}
107
108impl<'a> Drawer<'a> {
109    /// Create a drawer keyed by `id_salt` whose visibility is bound to `open`.
110    /// Defaults: anchored to the right, 420 pt wide, no title, dismisses on
111    /// `Esc` and backdrop click.
112    pub fn new(id_salt: impl Hash, open: &'a mut bool) -> Self {
113        Self {
114            id_salt: Id::new(id_salt),
115            open,
116            side: DrawerSide::Right,
117            width: 420.0,
118            title: None,
119            subtitle: None,
120            close_on_backdrop: true,
121            close_on_escape: true,
122        }
123    }
124
125    /// Anchor the drawer to the left or right edge. Default: [`DrawerSide::Right`].
126    #[inline]
127    pub fn side(mut self, side: DrawerSide) -> Self {
128        self.side = side;
129        self
130    }
131
132    /// Set the panel width in points. Default: 420. Clamped to at least 120.
133    #[inline]
134    pub fn width(mut self, width: f32) -> Self {
135        self.width = width.max(120.0);
136        self
137    }
138
139    /// Show a strong title at the top of the drawer, alongside the close "×"
140    /// button. When unset, no automatic chrome is rendered and the body
141    /// closure receives the full panel area.
142    pub fn title(mut self, title: impl Into<WidgetText>) -> Self {
143        self.title = Some(title.into());
144        self
145    }
146
147    /// Show a muted subtitle line below the title. Has no effect when
148    /// [`Drawer::title`] is unset.
149    pub fn subtitle(mut self, subtitle: impl Into<WidgetText>) -> Self {
150        self.subtitle = Some(subtitle.into());
151        self
152    }
153
154    /// Whether clicking the dimmed backdrop dismisses the drawer. Default: `true`.
155    #[inline]
156    pub fn close_on_backdrop(mut self, close: bool) -> Self {
157        self.close_on_backdrop = close;
158        self
159    }
160
161    /// Whether pressing `Esc` dismisses the drawer. Default: `true`.
162    #[inline]
163    pub fn close_on_escape(mut self, close: bool) -> Self {
164        self.close_on_escape = close;
165        self
166    }
167
168    /// Render the drawer. Returns `None` while the drawer is fully closed
169    /// (off-screen and not animating); otherwise `Some(R)` with the body
170    /// closure's return value.
171    ///
172    /// The closure is invoked every frame the panel is on-screen, including
173    /// while it slides in or out. Treat the body as ordinary layout — the
174    /// slide animation is applied by translating the parent `Area`.
175    pub fn show<R>(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui) -> R) -> Option<R> {
176        // --- Lifecycle: track open/closed transitions for focus restoration. ---
177        let focus_storage = Id::new(("elegance_drawer_focus", self.id_salt));
178        let mut focus_state: DrawerFocusState =
179            ctx.data(|d| d.get_temp(focus_storage).unwrap_or_default());
180        let is_open = *self.open;
181        let was_open = focus_state.was_open;
182        let just_opened = is_open && !was_open;
183        let just_closed = !is_open && was_open;
184
185        // --- Slide animation. 0.0 = fully off-screen, 1.0 = fully on. ---
186        let progress = ctx.animate_bool_with_time_and_easing(
187            Id::new(("elegance_drawer_progress", self.id_salt)),
188            is_open,
189            ANIMATION_DURATION,
190            emath::easing::cubic_in_out,
191        );
192
193        if just_opened {
194            focus_state.prev_focus = ctx.memory(|m| m.focused());
195        }
196        if just_closed {
197            if let Some(prev) = focus_state.prev_focus.take() {
198                ctx.memory_mut(|m| m.request_focus(prev));
199            }
200        }
201        focus_state.was_open = is_open;
202        ctx.data_mut(|d| d.insert_temp(focus_storage, focus_state));
203
204        // Skip painting entirely when fully closed and not animating.
205        if !is_open && progress < 0.001 {
206            return None;
207        }
208
209        let theme = Theme::current(ctx);
210        let p = &theme.palette;
211        let mut should_close = false;
212        let mut close_btn_id: Option<Id> = None;
213
214        // --- Geometry ----------------------------------------------------
215        let screen = ctx.content_rect();
216        let panel_w = self.width;
217        let slide = (1.0 - progress) * panel_w;
218        let panel_rect = match self.side {
219            DrawerSide::Right => Rect::from_min_max(
220                Pos2::new(screen.max.x - panel_w + slide, screen.min.y),
221                Pos2::new(screen.max.x + slide, screen.max.y),
222            ),
223            DrawerSide::Left => Rect::from_min_max(
224                Pos2::new(screen.min.x - slide, screen.min.y),
225                Pos2::new(screen.min.x + panel_w - slide, screen.max.y),
226            ),
227        };
228
229        // --- Backdrop ----------------------------------------------------
230        let backdrop_id = Id::new("elegance_drawer_backdrop").with(self.id_salt);
231        let backdrop_alpha = (progress * 150.0).round() as u8;
232        let backdrop = Area::new(backdrop_id)
233            .fixed_pos(screen.min)
234            .order(Order::Middle)
235            .constrain(false)
236            .show(ctx, |ui| {
237                ui.painter().rect_filled(
238                    screen,
239                    CornerRadius::ZERO,
240                    Color32::from_rgba_premultiplied(0, 0, 0, backdrop_alpha),
241                );
242                ui.allocate_rect(screen, Sense::click())
243            });
244        if self.close_on_backdrop && backdrop.inner.clicked() {
245            should_close = true;
246        }
247
248        // --- Panel -------------------------------------------------------
249        let panel_id = Id::new("elegance_drawer_panel").with(self.id_salt);
250        let title_text = self.title.as_ref().map(|t| t.text().to_string());
251        let title = self.title;
252        let subtitle = self.subtitle;
253        let side = self.side;
254
255        let result = Area::new(panel_id)
256            .order(Order::Foreground)
257            .fixed_pos(panel_rect.min)
258            // Without this, egui constrains the Area to stay on-screen, which
259            // snaps the content back into view during the slide-out animation
260            // even though our manually painted background is sliding off.
261            .constrain(false)
262            .show(ctx, |ui| {
263                ui.set_min_size(panel_rect.size());
264                ui.set_max_size(panel_rect.size());
265
266                // Clip body content to the panel rect — the Area defaults to
267                // clipping at the screen edge, but we want content that would
268                // overflow the panel to be clipped to the panel itself, and
269                // content that is partly off-screen during the slide to skip
270                // tessellation outside the panel's bounds.
271                ui.set_clip_rect(panel_rect);
272
273                // Promote the Ui to a dialog node so screen readers announce
274                // it as a window-like surface and Tab navigates within it.
275                ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
276                    node.set_role(accesskit::Role::Dialog);
277                    if let Some(label) = title_text {
278                        node.set_label(label);
279                    }
280                });
281
282                // Paint shadow + background fill at the full panel rect.
283                // Frame::fill would paint only as tall as its content, which
284                // leaves an unfilled gap at the bottom whenever the body
285                // closure is shorter than the viewport — drawers are full-
286                // height, so we want the fill regardless of content height.
287                let shadow = Shadow {
288                    offset: match side {
289                        DrawerSide::Right => [-12, 0],
290                        DrawerSide::Left => [12, 0],
291                    },
292                    blur: 28,
293                    spread: 0,
294                    color: Color32::from_black_alpha(110),
295                };
296                ui.painter()
297                    .add(shadow.as_shape(panel_rect, CornerRadius::ZERO));
298                ui.painter()
299                    .rect_filled(panel_rect, CornerRadius::ZERO, p.card);
300
301                let pad = theme.card_padding as i8;
302                let inner = Frame::new()
303                    .inner_margin(Margin::same(pad))
304                    .show(ui, |ui| {
305                        if title.is_some() {
306                            paint_header(
307                                ui,
308                                &theme,
309                                title.as_ref(),
310                                subtitle.as_ref(),
311                                &mut should_close,
312                                &mut close_btn_id,
313                            );
314                            ui.separator();
315                            ui.add_space(8.0);
316                        }
317                        add_contents(ui)
318                    })
319                    .inner;
320
321                // Inner-edge divider — paint last so it sits on top of the
322                // Frame fill. The other three sides of the panel touch the
323                // viewport edges and don't need a border.
324                let inner_x = match side {
325                    DrawerSide::Right => panel_rect.left(),
326                    DrawerSide::Left => panel_rect.right(),
327                };
328                ui.painter().line_segment(
329                    [
330                        Pos2::new(inner_x, panel_rect.top()),
331                        Pos2::new(inner_x, panel_rect.bottom()),
332                    ],
333                    Stroke::new(1.0, p.border),
334                );
335
336                inner
337            });
338
339        if self.close_on_escape && ctx.input(|i| i.key_pressed(Key::Escape)) {
340            should_close = true;
341        }
342
343        // On the first frame the drawer opens, move keyboard focus into it
344        // so Tab navigates within the dialog. Targets the close button (it's
345        // always interactive when chrome is rendered); without a title there
346        // is no intrinsic focus target and focus is left to the caller.
347        if just_opened {
348            if let Some(id) = close_btn_id {
349                ctx.memory_mut(|m| m.request_focus(id));
350            }
351        }
352
353        if should_close {
354            *self.open = false;
355        }
356
357        Some(result.inner)
358    }
359}
360
361/// Animation time for the slide transition, in seconds. Chosen to match
362/// the mockup's 260 ms cubic-bezier feel (eased via [`emath::easing::cubic_in_out`]).
363const ANIMATION_DURATION: f32 = 0.26;
364
365/// Persistent focus-lifecycle state for a single drawer, keyed by the
366/// drawer's `id_salt`. Stored via `ctx.data_mut`.
367#[derive(Clone, Copy, Default, Debug)]
368struct DrawerFocusState {
369    /// Whether the drawer was rendered open last frame. Used to detect
370    /// open/close transitions.
371    was_open: bool,
372    /// Which widget (if any) had keyboard focus at the moment the drawer
373    /// opened. Restored on close.
374    prev_focus: Option<Id>,
375}
376
377/// Paint the header row: title (strong) + optional muted subtitle on the
378/// left, close "×" button on the right.
379fn paint_header(
380    ui: &mut Ui,
381    theme: &Theme,
382    title: Option<&WidgetText>,
383    subtitle: Option<&WidgetText>,
384    should_close: &mut bool,
385    close_btn_id: &mut Option<Id>,
386) {
387    ui.horizontal(|ui| {
388        ui.vertical(|ui| {
389            if let Some(t) = title {
390                ui.add(egui::Label::new(theme.heading_text(t.text())));
391            }
392            if let Some(s) = subtitle {
393                ui.add(egui::Label::new(theme.muted_text(s.text())));
394            }
395        });
396        ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
397            let resp = drawer_close_button(ui);
398            if resp.clicked() {
399                *should_close = true;
400            }
401            *close_btn_id = Some(resp.id);
402        });
403    });
404}
405
406/// Render the drawer's close button. Returns its `Response` so the caller
407/// can route focus to it and observe `clicked()`. The accesskit label is
408/// set to `"Close"` explicitly so screen readers don't announce the "×"
409/// glyph as "multiplication sign."
410fn drawer_close_button(ui: &mut Ui) -> Response {
411    let inner = ui
412        .push_id("elegance_drawer_close", |ui| {
413            ui.add(Button::new("×").outline().size(ButtonSize::Small))
414        })
415        .inner;
416    let enabled = inner.enabled();
417    inner.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, "Close"));
418    inner
419}