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}