Skip to main content

armas_basic/components/
drawer.rs

1//! Drawer Component (vaul-style)
2//!
3//! Bottom drawer with drag handle and gesture-based dismissal,
4//! styled like shadcn/ui Drawer (which uses vaul library).
5//!
6//! For side panels, use [`Sheet`](super::Sheet) instead.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! # use egui::Context;
12//! # fn example(ctx: &Context) {
13//! use armas_basic::{Drawer, Theme};
14//!
15//! let theme = Theme::dark();
16//! let mut open = true;
17//!
18//! let response = Drawer::new("my-drawer")
19//!     .open(open)
20//!     .title("Edit Profile")
21//!     .description("Make changes to your profile here.")
22//!     .show(ctx, &theme, |ui| {
23//!         ui.label("Content goes here");
24//!     });
25//!
26//! if response.closed {
27//!     open = false;
28//! }
29//! # }
30//! ```
31
32use crate::Theme;
33use egui::{vec2, Color32, Key, Pos2, Rect, Sense, Stroke, Ui};
34
35// vaul Drawer constants
36const DRAWER_MAX_HEIGHT_RATIO: f32 = 0.96; // max-h-[96%] like vaul
37const DRAWER_DEFAULT_HEIGHT: f32 = 400.0; // Default height when not dragging
38const _DRAWER_MIN_HEIGHT: f32 = 100.0; // Minimum before auto-close (reserved for snap points)
39
40const HANDLE_WIDTH: f32 = 48.0; // w-12 (mx-auto)
41const HANDLE_HEIGHT: f32 = 6.0; // h-1.5
42const HANDLE_TOP_MARGIN: f32 = 16.0; // pt-4
43const HANDLE_ROUNDING: f32 = 9999.0; // rounded-full
44
45const HEADER_PADDING: f32 = 16.0; // p-4
46const CONTENT_PADDING: f32 = 16.0; // p-4
47const GAP_Y: f32 = 4.0; // gap-1 between title and description
48
49const BACKDROP_ALPHA: f32 = 0.8; // bg-black/80
50const BORDER_ROUNDING: f32 = 10.0; // rounded-t-[10px]
51
52// Drag physics
53const DRAG_CLOSE_THRESHOLD: f32 = 0.5; // Close if dragged past 50% of height
54const DRAG_VELOCITY_THRESHOLD: f32 = 500.0; // Close if velocity exceeds this
55
56/// Drawer snap points for partial open states
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum DrawerSnapPoint {
59    /// Closed (0%)
60    Closed,
61    /// Partially open (custom percentage 0.0-1.0)
62    Partial(f32),
63    /// Fully open (100%)
64    Full,
65}
66
67impl DrawerSnapPoint {
68    /// Convert snap point to ratio (0.0-1.0)
69    #[must_use]
70    pub const fn to_ratio(&self) -> f32 {
71        match self {
72            Self::Closed => 0.0,
73            Self::Partial(ratio) => ratio.clamp(0.0, 1.0),
74            Self::Full => 1.0,
75        }
76    }
77}
78
79/// Drawer component (vaul-style bottom sheet)
80///
81/// A bottom drawer with drag handle that can be dismissed by dragging down.
82/// For side panels, use [`Sheet`](super::Sheet).
83pub struct Drawer {
84    id: egui::Id,
85    title: Option<String>,
86    description: Option<String>,
87    show_handle: bool,
88    show_backdrop: bool,
89    is_open: bool,
90    snap_points: Vec<DrawerSnapPoint>,
91    height: Option<f32>,
92}
93
94impl Drawer {
95    /// Create a new drawer
96    pub fn new(id: impl Into<egui::Id>) -> Self {
97        Self {
98            id: id.into(),
99            title: None,
100            description: None,
101            show_handle: true,
102            show_backdrop: true,
103            is_open: false,
104            snap_points: vec![DrawerSnapPoint::Full],
105            height: None,
106        }
107    }
108
109    /// Set the drawer open state
110    #[must_use]
111    pub const fn open(mut self, open: bool) -> Self {
112        self.is_open = open;
113        self
114    }
115
116    /// Set the title (`DrawerTitle` equivalent)
117    #[must_use]
118    pub fn title(mut self, title: impl Into<String>) -> Self {
119        self.title = Some(title.into());
120        self
121    }
122
123    /// Set the description (`DrawerDescription` equivalent)
124    #[must_use]
125    pub fn description(mut self, description: impl Into<String>) -> Self {
126        self.description = Some(description.into());
127        self
128    }
129
130    /// Show or hide the drag handle (default: true)
131    #[must_use]
132    pub const fn show_handle(mut self, show: bool) -> Self {
133        self.show_handle = show;
134        self
135    }
136
137    /// Show or hide the backdrop overlay (default: true)
138    #[must_use]
139    pub const fn show_backdrop(mut self, show: bool) -> Self {
140        self.show_backdrop = show;
141        self
142    }
143
144    /// Set snap points for partial open states
145    #[must_use]
146    pub fn snap_points(mut self, points: Vec<DrawerSnapPoint>) -> Self {
147        self.snap_points = points;
148        self
149    }
150
151    /// Set a fixed height for the drawer
152    #[must_use]
153    pub const fn height(mut self, height: f32) -> Self {
154        self.height = Some(height);
155        self
156    }
157
158    /// Show the drawer and render content
159    pub fn show(
160        &mut self,
161        ctx: &egui::Context,
162        theme: &Theme,
163        content: impl FnOnce(&mut Ui),
164    ) -> DrawerResponse {
165        let mut closed = false;
166        let snap_point = DrawerSnapPoint::Full;
167
168        if !self.is_open {
169            let dummy = egui::Area::new(self.id.with("drawer_empty"))
170                .order(egui::Order::Background)
171                .fixed_pos(egui::Pos2::ZERO)
172                .show(ctx, |_| {})
173                .response;
174            return DrawerResponse {
175                response: dummy,
176                closed: false,
177                snap_point,
178            };
179        }
180
181        #[allow(deprecated)]
182        let screen_rect = ctx.screen_rect();
183
184        // Get or initialize drag state
185        let drag_state_id = self.id.with("drag_state");
186        let mut drag_offset: f32 = ctx.data_mut(|d| d.get_temp(drag_state_id).unwrap_or(0.0));
187        let velocity_id = self.id.with("velocity");
188        let mut last_drag_delta: f32 = ctx.data_mut(|d| d.get_temp(velocity_id).unwrap_or(0.0));
189
190        // Calculate drawer dimensions
191        let max_height = screen_rect.height() * DRAWER_MAX_HEIGHT_RATIO;
192        let base_height = self.height.unwrap_or(DRAWER_DEFAULT_HEIGHT).min(max_height);
193        let current_height = (base_height - drag_offset).max(0.0);
194
195        // Draw backdrop
196        if self.show_backdrop {
197            let backdrop_alpha = (BACKDROP_ALPHA * (current_height / base_height)).max(0.0);
198            let backdrop_color = Color32::from_black_alpha((255.0 * backdrop_alpha) as u8);
199
200            egui::Area::new(self.id.with("backdrop"))
201                .order(egui::Order::Middle)
202                .interactable(true)
203                .fixed_pos(screen_rect.min)
204                .show(ctx, |ui| {
205                    let backdrop_response =
206                        ui.allocate_response(screen_rect.size(), Sense::click());
207                    ui.painter().rect_filled(screen_rect, 0.0, backdrop_color);
208
209                    if backdrop_response.clicked() {
210                        closed = true;
211                    }
212                });
213        }
214
215        // Calculate drawer rect (from bottom)
216        let drawer_rect = Rect::from_min_size(
217            Pos2::new(screen_rect.left(), screen_rect.bottom() - current_height),
218            vec2(screen_rect.width(), current_height),
219        );
220
221        // Draw the drawer panel
222        let area_response = egui::Area::new(self.id)
223            .order(egui::Order::Foreground)
224            .fixed_pos(drawer_rect.min)
225            .show(ctx, |ui| {
226                ui.set_clip_rect(drawer_rect);
227
228                // Background with rounded top corners
229                ui.painter()
230                    .rect_filled(drawer_rect, BORDER_ROUNDING, theme.background());
231
232                // Top border
233                ui.painter().hline(
234                    drawer_rect.x_range(),
235                    drawer_rect.top(),
236                    Stroke::new(1.0, theme.border()),
237                );
238
239                // Drag handle
240                let handle_response = if self.show_handle {
241                    let handle_rect = Rect::from_center_size(
242                        Pos2::new(
243                            drawer_rect.center().x,
244                            drawer_rect.top() + HANDLE_TOP_MARGIN + HANDLE_HEIGHT / 2.0,
245                        ),
246                        vec2(HANDLE_WIDTH, HANDLE_HEIGHT),
247                    );
248
249                    // Larger drag area for easier grabbing
250                    let drag_area = Rect::from_center_size(
251                        handle_rect.center(),
252                        vec2(screen_rect.width(), HANDLE_TOP_MARGIN * 2.0 + HANDLE_HEIGHT),
253                    );
254
255                    let drag_response =
256                        ui.interact(drag_area, self.id.with("handle"), Sense::drag());
257
258                    // Draw handle
259                    ui.painter()
260                        .rect_filled(handle_rect, HANDLE_ROUNDING, theme.muted());
261
262                    Some(drag_response)
263                } else {
264                    None
265                };
266
267                // Handle dragging
268                if let Some(drag_resp) = handle_response {
269                    if drag_resp.dragged() {
270                        let delta = drag_resp.drag_delta().y;
271                        drag_offset += delta;
272                        drag_offset = drag_offset.max(0.0); // Can't drag up past full height
273                        last_drag_delta = delta;
274                        ctx.request_repaint();
275                    }
276
277                    if drag_resp.drag_stopped() {
278                        // Check if we should close based on position or velocity
279                        let drag_ratio = drag_offset / base_height;
280                        let velocity = last_drag_delta * 60.0; // Approximate velocity
281
282                        if drag_ratio > DRAG_CLOSE_THRESHOLD || velocity > DRAG_VELOCITY_THRESHOLD {
283                            closed = true;
284                        }
285                        // Snap back to nearest snap point (or reset on close)
286                        drag_offset = 0.0;
287                        last_drag_delta = 0.0;
288                    }
289                }
290
291                // Content area starts after handle
292                let content_top = if self.show_handle {
293                    drawer_rect.top() + HANDLE_TOP_MARGIN * 2.0 + HANDLE_HEIGHT
294                } else {
295                    drawer_rect.top()
296                };
297
298                let content_rect =
299                    Rect::from_min_max(Pos2::new(drawer_rect.left(), content_top), drawer_rect.max);
300
301                let mut content_ui = ui.new_child(
302                    egui::UiBuilder::new()
303                        .max_rect(content_rect)
304                        .layout(egui::Layout::top_down(egui::Align::Min)),
305                );
306
307                content_ui.set_clip_rect(content_rect);
308
309                // Header section (title + description)
310                if self.title.is_some() || self.description.is_some() {
311                    content_ui.horizontal(|ui| {
312                        ui.add_space(HEADER_PADDING);
313                        ui.vertical(|ui| {
314                            ui.set_width(drawer_rect.width() - HEADER_PADDING * 2.0);
315
316                            if let Some(title) = &self.title {
317                                ui.label(
318                                    egui::RichText::new(title)
319                                        .size(theme.typography.xl)
320                                        .strong()
321                                        .color(theme.foreground()),
322                                );
323                            }
324
325                            if let Some(desc) = &self.description {
326                                ui.add_space(GAP_Y);
327                                ui.label(
328                                    egui::RichText::new(desc)
329                                        .size(theme.typography.base)
330                                        .color(theme.muted_foreground()),
331                                );
332                            }
333                        });
334                    });
335                    content_ui.add_space(HEADER_PADDING);
336                }
337
338                // Main content with padding
339                content_ui.horizontal(|ui| {
340                    ui.add_space(CONTENT_PADDING);
341                    ui.vertical(|ui| {
342                        ui.set_width(drawer_rect.width() - CONTENT_PADDING * 2.0);
343                        content(ui);
344                    });
345                    ui.add_space(CONTENT_PADDING);
346                });
347            });
348
349        // Save drag state
350        ctx.data_mut(|d| {
351            d.insert_temp(drag_state_id, drag_offset);
352            d.insert_temp(velocity_id, last_drag_delta);
353        });
354
355        // Handle ESC key
356        if ctx.input(|i| i.key_pressed(Key::Escape)) {
357            closed = true;
358        }
359
360        // Reset drag state if closing
361        if closed {
362            ctx.data_mut(|d| {
363                d.insert_temp(drag_state_id, 0.0f32);
364                d.insert_temp(velocity_id, 0.0f32);
365            });
366        }
367
368        DrawerResponse {
369            response: area_response.response,
370            closed,
371            snap_point,
372        }
373    }
374}
375
376/// Response from showing a drawer
377pub struct DrawerResponse {
378    /// The UI response
379    pub response: egui::Response,
380    /// Whether the drawer was closed this frame
381    pub closed: bool,
382    /// Current snap point (for partial open states)
383    pub snap_point: DrawerSnapPoint,
384}
385
386impl DrawerResponse {
387    /// Check if the drawer was closed
388    #[must_use]
389    pub const fn closed(&self) -> bool {
390        self.closed
391    }
392}