Skip to main content

armas_basic/components/
sheet.rs

1//! Sheet Component
2//!
3//! Slide-out side panels styled like shadcn/ui Sheet.
4//! Extends from the edge of the screen for navigation, settings, or forms.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # use egui::Context;
10//! # fn example(ctx: &Context) {
11//! use armas_basic::{Sheet, SheetSide, Theme};
12//!
13//! let theme = Theme::dark();
14//! let mut open = true;
15//!
16//! Sheet::new("my-sheet")
17//!     .side(SheetSide::Right)
18//!     .open(open)
19//!     .title("Edit Profile")
20//!     .description("Make changes to your profile here.")
21//!     .show(ctx, &theme, |ui| {
22//!         ui.label("Content goes here");
23//!     });
24//! # }
25//! ```
26
27use crate::icon;
28use crate::Theme;
29use egui::{vec2, Color32, Key, Pos2, Rect, Sense, Stroke, Ui};
30
31// shadcn Sheet constants
32const SHEET_WIDTH_SM: f32 = 320.0; // sm:max-w-sm (default for side sheets)
33const SHEET_WIDTH_MD: f32 = 420.0; // roughly max-w-md
34const SHEET_WIDTH_LG: f32 = 540.0; // lg:max-w-lg
35const SHEET_WIDTH_XL: f32 = 672.0; // xl:max-w-xl
36const SHEET_HEIGHT_DEFAULT: f32 = 400.0; // For top/bottom sheets
37
38const HEADER_PADDING: f32 = 24.0; // p-6
39const CONTENT_PADDING_X: f32 = 24.0; // px-6
40const _FOOTER_PADDING: f32 = 24.0; // p-6 (reserved for future footer support)
41const GAP_Y: f32 = 8.0; // gap-2 between title and description
42
43const CLOSE_BUTTON_SIZE: f32 = 16.0; // h-4 w-4
44const CLOSE_BUTTON_OFFSET: f32 = 16.0; // top-4 right-4
45const CLOSE_BUTTON_ROUNDING: f32 = 4.0; // rounded-sm
46
47const BACKDROP_ALPHA: f32 = 0.8; // bg-black/80
48const BORDER_WIDTH: f32 = 1.0;
49
50/// Side from which the sheet slides in
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum SheetSide {
53    /// Slide from top
54    Top,
55    /// Slide from right (default, like shadcn)
56    #[default]
57    Right,
58    /// Slide from bottom
59    Bottom,
60    /// Slide from left
61    Left,
62}
63
64/// Sheet size presets
65#[derive(Debug, Clone, Copy, PartialEq, Default)]
66pub enum SheetSize {
67    /// Small (320px)
68    #[default]
69    Small,
70    /// Medium (420px)
71    Medium,
72    /// Large (540px)
73    Large,
74    /// Extra large (672px)
75    XLarge,
76    /// Full width/height
77    Full,
78    /// Custom size in pixels
79    Custom(f32),
80}
81
82impl SheetSize {
83    const fn to_pixels(self) -> f32 {
84        match self {
85            Self::Small => SHEET_WIDTH_SM,
86            Self::Medium => SHEET_WIDTH_MD,
87            Self::Large => SHEET_WIDTH_LG,
88            Self::XLarge => SHEET_WIDTH_XL,
89            Self::Full => 0.0, // Calculated at runtime
90            Self::Custom(px) => px,
91        }
92    }
93}
94
95/// Sheet component
96///
97/// A panel that slides out from the edge of the screen,
98/// styled like shadcn/ui Sheet component.
99pub struct Sheet {
100    id: egui::Id,
101    side: SheetSide,
102    size: SheetSize,
103    title: Option<String>,
104    description: Option<String>,
105    show_close_button: bool,
106    show_backdrop: bool,
107    is_open: bool,
108}
109
110impl Sheet {
111    /// Create a new sheet
112    pub fn new(id: impl Into<egui::Id>) -> Self {
113        Self {
114            id: id.into(),
115            side: SheetSide::Right,
116            size: SheetSize::Small,
117            title: None,
118            description: None,
119            show_close_button: true,
120            show_backdrop: true,
121            is_open: false,
122        }
123    }
124
125    /// Set which side the sheet slides from
126    #[must_use]
127    pub const fn side(mut self, side: SheetSide) -> Self {
128        self.side = side;
129        self
130    }
131
132    /// Set the sheet size
133    #[must_use]
134    pub const fn size(mut self, size: SheetSize) -> Self {
135        self.size = size;
136        self
137    }
138
139    /// Set the sheet open state
140    #[must_use]
141    pub const fn open(mut self, open: bool) -> Self {
142        self.is_open = open;
143        self
144    }
145
146    /// Set the title (`SheetTitle` equivalent)
147    #[must_use]
148    pub fn title(mut self, title: impl Into<String>) -> Self {
149        self.title = Some(title.into());
150        self
151    }
152
153    /// Set the description (`SheetDescription` equivalent)
154    #[must_use]
155    pub fn description(mut self, description: impl Into<String>) -> Self {
156        self.description = Some(description.into());
157        self
158    }
159
160    /// Show or hide the close button (default: true)
161    #[must_use]
162    pub const fn show_close_button(mut self, show: bool) -> Self {
163        self.show_close_button = show;
164        self
165    }
166
167    /// Show or hide the backdrop overlay (default: true)
168    #[must_use]
169    pub const fn show_backdrop(mut self, show: bool) -> Self {
170        self.show_backdrop = show;
171        self
172    }
173
174    /// Show the sheet and render content
175    pub fn show(
176        &mut self,
177        ctx: &egui::Context,
178        theme: &Theme,
179        content: impl FnOnce(&mut Ui),
180    ) -> SheetResponse {
181        let mut closed = false;
182
183        if !self.is_open {
184            let dummy = egui::Area::new(self.id.with("sheet_empty"))
185                .order(egui::Order::Background)
186                .fixed_pos(egui::Pos2::ZERO)
187                .show(ctx, |_| {})
188                .response;
189            return SheetResponse {
190                response: dummy,
191                closed: false,
192            };
193        }
194
195        #[allow(deprecated)]
196        let screen_rect = ctx.screen_rect(); // Full viewport for overlay
197
198        // Calculate sheet dimensions
199        let is_horizontal = matches!(self.side, SheetSide::Left | SheetSide::Right);
200        let sheet_size = if self.size == SheetSize::Full {
201            if is_horizontal {
202                screen_rect.width()
203            } else {
204                screen_rect.height()
205            }
206        } else {
207            self.size.to_pixels()
208        };
209
210        // For top/bottom, use a reasonable default if not full
211        let sheet_size = if !is_horizontal && self.size != SheetSize::Full {
212            sheet_size.min(SHEET_HEIGHT_DEFAULT)
213        } else {
214            sheet_size
215        };
216
217        // Draw backdrop
218        if self.show_backdrop {
219            let backdrop_color = Color32::from_black_alpha((255.0 * BACKDROP_ALPHA) as u8);
220
221            egui::Area::new(self.id.with("backdrop"))
222                .order(egui::Order::Middle)
223                .interactable(true)
224                .fixed_pos(screen_rect.min)
225                .show(ctx, |ui| {
226                    let backdrop_response =
227                        ui.allocate_response(screen_rect.size(), Sense::click());
228                    ui.painter().rect_filled(screen_rect, 0.0, backdrop_color);
229
230                    if backdrop_response.clicked() {
231                        closed = true;
232                    }
233                });
234        }
235
236        // Calculate sheet rect based on side
237        let sheet_rect = match self.side {
238            SheetSide::Right => Rect::from_min_size(
239                Pos2::new(screen_rect.right() - sheet_size, screen_rect.top()),
240                vec2(sheet_size, screen_rect.height()),
241            ),
242            SheetSide::Left => Rect::from_min_size(
243                screen_rect.left_top(),
244                vec2(sheet_size, screen_rect.height()),
245            ),
246            SheetSide::Top => Rect::from_min_size(
247                screen_rect.left_top(),
248                vec2(screen_rect.width(), sheet_size),
249            ),
250            SheetSide::Bottom => Rect::from_min_size(
251                Pos2::new(screen_rect.left(), screen_rect.bottom() - sheet_size),
252                vec2(screen_rect.width(), sheet_size),
253            ),
254        };
255
256        // Draw the sheet panel
257        let area_response = egui::Area::new(self.id)
258            .order(egui::Order::Foreground)
259            .fixed_pos(sheet_rect.min)
260            .show(ctx, |ui| {
261                ui.set_clip_rect(sheet_rect);
262
263                // Background with border
264                ui.painter()
265                    .rect_filled(sheet_rect, 0.0, theme.background());
266
267                // Border on the edge facing the content
268                let border_stroke = Stroke::new(BORDER_WIDTH, theme.border());
269                match self.side {
270                    SheetSide::Right => {
271                        ui.painter()
272                            .vline(sheet_rect.left(), sheet_rect.y_range(), border_stroke);
273                    }
274                    SheetSide::Left => {
275                        ui.painter()
276                            .vline(sheet_rect.right(), sheet_rect.y_range(), border_stroke);
277                    }
278                    SheetSide::Top => {
279                        ui.painter().hline(
280                            sheet_rect.x_range(),
281                            sheet_rect.bottom(),
282                            border_stroke,
283                        );
284                    }
285                    SheetSide::Bottom => {
286                        ui.painter()
287                            .hline(sheet_rect.x_range(), sheet_rect.top(), border_stroke);
288                    }
289                }
290
291                // Close button (top-right corner)
292                if self.show_close_button {
293                    let close_rect = Rect::from_min_size(
294                        Pos2::new(
295                            sheet_rect.right() - CLOSE_BUTTON_OFFSET - CLOSE_BUTTON_SIZE,
296                            sheet_rect.top() + CLOSE_BUTTON_OFFSET,
297                        ),
298                        vec2(CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE),
299                    );
300
301                    let close_response = ui.interact(
302                        close_rect.expand(4.0), // Larger hit area
303                        self.id.with("close"),
304                        Sense::click(),
305                    );
306
307                    // Hover background
308                    if close_response.hovered() {
309                        ui.painter().rect_filled(
310                            close_rect.expand(4.0),
311                            CLOSE_BUTTON_ROUNDING,
312                            theme.accent(),
313                        );
314                    }
315
316                    // Draw close icon
317                    let icon_color = if close_response.hovered() {
318                        theme.foreground()
319                    } else {
320                        theme.muted_foreground()
321                    };
322                    icon::draw_close(ui.painter(), close_rect, icon_color);
323
324                    if close_response.clicked() {
325                        closed = true;
326                    }
327                }
328
329                // Content area
330                let content_rect =
331                    Rect::from_min_max(sheet_rect.min + vec2(0.0, 0.0), sheet_rect.max);
332
333                let mut content_ui = ui.new_child(
334                    egui::UiBuilder::new()
335                        .max_rect(content_rect)
336                        .layout(egui::Layout::top_down(egui::Align::Min)),
337                );
338
339                content_ui.set_clip_rect(content_rect);
340
341                // Header section (title + description)
342                if self.title.is_some() || self.description.is_some() {
343                    content_ui.add_space(HEADER_PADDING);
344                    content_ui.horizontal(|ui| {
345                        ui.add_space(HEADER_PADDING);
346                        ui.vertical(|ui| {
347                            ui.set_width(
348                                sheet_rect.width()
349                                    - HEADER_PADDING * 2.0
350                                    - CLOSE_BUTTON_SIZE
351                                    - CLOSE_BUTTON_OFFSET,
352                            );
353
354                            if let Some(title) = &self.title {
355                                ui.label(
356                                    egui::RichText::new(title)
357                                        .size(18.0)
358                                        .strong()
359                                        .color(theme.foreground()),
360                                );
361                            }
362
363                            if let Some(desc) = &self.description {
364                                ui.add_space(GAP_Y);
365                                ui.label(
366                                    egui::RichText::new(desc)
367                                        .size(14.0)
368                                        .color(theme.muted_foreground()),
369                                );
370                            }
371                        });
372                    });
373                    content_ui.add_space(HEADER_PADDING);
374                }
375
376                // Main content with horizontal padding
377                content_ui.horizontal(|ui| {
378                    ui.add_space(CONTENT_PADDING_X);
379                    ui.vertical(|ui| {
380                        ui.set_width(sheet_rect.width() - CONTENT_PADDING_X * 2.0);
381                        content(ui);
382                    });
383                    ui.add_space(CONTENT_PADDING_X);
384                });
385            });
386
387        // Handle ESC key
388        if ctx.input(|i| i.key_pressed(Key::Escape)) {
389            closed = true;
390        }
391
392        SheetResponse {
393            response: area_response.response,
394            closed,
395        }
396    }
397}
398
399/// Response from showing a sheet
400pub struct SheetResponse {
401    /// The UI response
402    pub response: egui::Response,
403    /// Whether the sheet was closed this frame (via close button, backdrop, or ESC)
404    pub closed: bool,
405}
406
407impl SheetResponse {
408    /// Check if the sheet was closed
409    #[must_use]
410    pub const fn closed(&self) -> bool {
411        self.closed
412    }
413}