egui_material3/
drawer.rs

1use crate::theme::get_global_color;
2use egui::{
3    ecolor::Color32, 
4    epaint::{Stroke, CornerRadius, Shadow},
5    Rect, Response, Sense, Ui, Vec2, Widget, Id, Area, SidePanel, Order, pos2,
6};
7
8/// Material Design navigation drawer variants.
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub enum DrawerVariant {
11    Permanent,
12    Dismissible, 
13    Modal,
14}
15
16/// Material Design navigation drawer component.
17///
18/// Navigation drawers provide access to destinations and app functionality.
19/// They can be permanently on-screen or controlled by navigation triggers.
20///
21/// ```
22/// # egui::__run_test_ui(|ui| {
23/// let mut drawer_open = true;
24/// 
25/// let drawer = MaterialDrawer::new(DrawerVariant::Permanent, &mut drawer_open)
26///     .header("Mail", Some("email@material.io"))
27///     .item("Inbox", Some("inbox"), true)
28///     .item("Sent", Some("send"), false)
29///     .item("Drafts", Some("drafts"), false);
30///
31/// drawer.show(ui.ctx());
32/// # });
33/// ```
34pub struct MaterialDrawer<'a> {
35    variant: DrawerVariant,
36    open: &'a mut bool,
37    width: f32,
38    header_title: Option<String>,
39    header_subtitle: Option<String>,
40    items: Vec<DrawerItem>,
41    corner_radius: CornerRadius,
42    elevation: Option<Shadow>,
43    id: Id,
44}
45
46pub struct DrawerItem {
47    pub text: String,
48    pub icon: Option<String>,
49    pub active: bool,
50    pub on_click: Option<Box<dyn Fn() + Send + Sync>>,
51}
52
53impl DrawerItem {
54    pub fn new(text: impl Into<String>) -> Self {
55        Self {
56            text: text.into(),
57            icon: None,
58            active: false,
59            on_click: None,
60        }
61    }
62    
63    pub fn icon(mut self, icon: impl Into<String>) -> Self {
64        self.icon = Some(icon.into());
65        self
66    }
67    
68    pub fn active(mut self, active: bool) -> Self {
69        self.active = active;
70        self
71    }
72    
73    pub fn on_click<F>(mut self, callback: F) -> Self 
74    where
75        F: Fn() + Send + Sync + 'static,
76    {
77        self.on_click = Some(Box::new(callback));
78        self
79    }
80}
81
82impl<'a> MaterialDrawer<'a> {
83    /// Create a new navigation drawer.
84    pub fn new(variant: DrawerVariant, open: &'a mut bool) -> Self {
85        let id = Id::new(format!("material_drawer_{:?}", variant));
86        Self {
87            variant,
88            open,
89            width: 256.0, // Standard Material Design drawer width
90            header_title: None,
91            header_subtitle: None,
92            items: Vec::new(),
93            corner_radius: CornerRadius::ZERO,
94            elevation: None,
95            id,
96        }
97    }
98
99    /// Create a new navigation drawer with custom ID.
100    pub fn new_with_id(variant: DrawerVariant, open: &'a mut bool, id: Id) -> Self {
101        Self {
102            variant,
103            open,
104            width: 256.0,
105            header_title: None,
106            header_subtitle: None,
107            items: Vec::new(),
108            corner_radius: CornerRadius::ZERO,
109            elevation: None,
110            id,
111        }
112    }
113
114    /// Set drawer width.
115    pub fn width(mut self, width: f32) -> Self {
116        self.width = width;
117        self
118    }
119
120    /// Add header with title and optional subtitle.
121    pub fn header(mut self, title: impl Into<String>, subtitle: Option<impl Into<String>>) -> Self {
122        self.header_title = Some(title.into());
123        self.header_subtitle = subtitle.map(|s| s.into());
124        self
125    }
126
127    /// Add a navigation item.
128    pub fn item(mut self, text: impl Into<String>, icon: Option<impl Into<String>>, active: bool) -> Self {
129        self.items.push(DrawerItem {
130            text: text.into(),
131            icon: icon.map(|i| i.into()),
132            active,
133            on_click: None,
134        });
135        self
136    }
137
138    /// Add a navigation item with callback.
139    pub fn item_with_callback<F>(mut self, text: impl Into<String>, icon: Option<impl Into<String>>, active: bool, callback: F) -> Self
140    where
141        F: Fn() + Send + Sync + 'static,
142    {
143        self.items.push(DrawerItem {
144            text: text.into(),
145            icon: icon.map(|i| i.into()),
146            active,
147            on_click: Some(Box::new(callback)),
148        });
149        self
150    }
151
152    /// Set corner radius.
153    pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
154        self.corner_radius = corner_radius.into();
155        self
156    }
157
158    /// Set elevation shadow.
159    pub fn elevation(mut self, elevation: impl Into<Shadow>) -> Self {
160        self.elevation = Some(elevation.into());
161        self
162    }
163
164    fn get_drawer_style(&self) -> (Color32, Option<Stroke>, bool) {
165        let md_surface = get_global_color("surface");
166        let md_outline = get_global_color("outline");
167        
168        match self.variant {
169            DrawerVariant::Permanent => {
170                // Permanent drawer: surface with border
171                (md_surface, Some(Stroke::new(1.0, md_outline)), false)
172            },
173            DrawerVariant::Modal => {
174                // Modal drawer: surface with elevation
175                (md_surface, None, true)
176            },
177            DrawerVariant::Dismissible => {
178                // Dismissible drawer: surface with border
179                (md_surface, Some(Stroke::new(1.0, md_outline)), false)
180            },
181        }
182    }
183
184    /// Show the drawer using appropriate egui layout.
185    pub fn show(self, ctx: &egui::Context) -> Response {
186        match self.variant {
187            DrawerVariant::Permanent => self.show_permanent(ctx),
188            DrawerVariant::Dismissible => self.show_dismissible(ctx),
189            DrawerVariant::Modal => self.show_modal(ctx),
190        }
191    }
192
193    fn show_permanent(self, ctx: &egui::Context) -> Response {
194        SidePanel::left(self.id.with("permanent"))
195            .default_width(self.width)
196            .resizable(false)
197            .show(ctx, |ui| {
198                self.render_drawer_content(ui)
199            })
200            .response
201    }
202
203    fn show_dismissible(self, ctx: &egui::Context) -> Response {
204        if *self.open {
205            SidePanel::left(self.id.with("dismissible"))
206                .default_width(self.width)
207                .resizable(false)
208                .show(ctx, |ui| {
209                    self.render_drawer_content(ui)
210                })
211                .response
212        } else {
213            // Return empty response when closed
214            Area::new(self.id.with("dismissible_dummy"))
215                .fixed_pos(pos2(-1000.0, -1000.0)) // Place offscreen
216                .show(ctx, |ui| {
217                    ui.allocate_response(Vec2::ZERO, Sense::hover())
218                })
219                .response
220        }
221    }
222
223    fn show_modal(self, ctx: &egui::Context) -> Response {
224        if *self.open {
225            // Draw scrim background
226            let screen_rect = ctx.screen_rect();
227            Area::new(self.id.with("modal_scrim"))
228                .order(Order::Background)
229                .show(ctx, |ui| {
230                    let scrim_response = ui.allocate_response(screen_rect.size(), Sense::click());
231                    ui.painter().rect_filled(
232                        screen_rect,
233                        CornerRadius::ZERO,
234                        Color32::from_rgba_unmultiplied(0, 0, 0, 128), // Semi-transparent scrim
235                    );
236                    
237                    // Close drawer if scrim is clicked
238                    if scrim_response.clicked() {
239                        *self.open = false;
240                    }
241                });
242
243            // Draw the actual modal drawer
244            Area::new(self.id.with("modal_drawer"))
245                .order(Order::Foreground)
246                .fixed_pos(pos2(0.0, 0.0))
247                .show(ctx, |ui| {
248                    ui.set_width(self.width);
249                    ui.set_height(screen_rect.height());
250                    self.render_drawer_content(ui)
251                })
252                .response
253        } else {
254            // Return empty response when closed
255            Area::new(self.id.with("modal_dummy"))
256                .fixed_pos(pos2(-1000.0, -1000.0)) // Place offscreen
257                .show(ctx, |ui| {
258                    ui.allocate_response(Vec2::ZERO, Sense::hover())
259                })
260                .response
261        }
262    }
263
264    fn render_drawer_content(self, ui: &mut Ui) -> Response {
265        let (background_color, border_stroke, has_elevation) = self.get_drawer_style();
266        
267        // Handle ESC key for dismissible and modal drawers
268        if matches!(self.variant, DrawerVariant::Dismissible | DrawerVariant::Modal) {
269            if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
270                *self.open = false;
271            }
272        }
273
274        // Calculate drawer dimensions
275        let header_height = if self.header_title.is_some() { 64.0 } else { 0.0 };
276        let item_height = 48.0;
277        let items_height = self.items.len() as f32 * item_height;
278        let _total_height = header_height + items_height;
279
280        let available_rect = ui.available_rect_before_wrap();
281        let drawer_rect = Rect::from_min_size(available_rect.min, Vec2::new(self.width, available_rect.height()));
282
283        // Draw drawer background
284        ui.painter().rect_filled(drawer_rect, self.corner_radius, background_color);
285        
286        // Draw border if present
287        if let Some(stroke) = border_stroke {
288            ui.painter().rect_stroke(drawer_rect, self.corner_radius, stroke, egui::epaint::StrokeKind::Outside);
289        }
290
291        // Draw elevation shadow if needed (simplified approach for egui)
292        if has_elevation {
293            // Draw a simple drop shadow by drawing darker rectangles behind
294            let shadow_offset = Vec2::new(0.0, 4.0);
295            let shadow_color = Color32::from_rgba_unmultiplied(0, 0, 0, 20);
296            
297            // Draw multiple shadow layers for better effect
298            for i in 1..=3 {
299                let shadow_rect = drawer_rect.translate(shadow_offset * i as f32 * 0.5);
300                ui.painter().rect_filled(shadow_rect, self.corner_radius, shadow_color);
301            }
302        }
303
304        let mut current_y = drawer_rect.min.y;
305
306        // Draw header if present
307        if let Some(title) = &self.header_title {
308            let header_rect = Rect::from_min_size(
309                egui::pos2(drawer_rect.min.x, current_y),
310                Vec2::new(self.width, header_height)
311            );
312
313            // Header background (slightly different color)
314            let header_color = background_color.linear_multiply(0.95);
315            ui.painter().rect_filled(header_rect, CornerRadius::ZERO, header_color);
316
317            // Header text
318            let title_pos = egui::pos2(
319                header_rect.min.x + 16.0,
320                header_rect.min.y + 16.0
321            );
322            ui.painter().text(
323                title_pos,
324                egui::Align2::LEFT_TOP,
325                title,
326                egui::TextStyle::Heading.resolve(ui.style()),
327                get_global_color("onSurface")
328            );
329
330            if let Some(subtitle) = &self.header_subtitle {
331                let subtitle_pos = egui::pos2(
332                    header_rect.min.x + 16.0,
333                    header_rect.min.y + 36.0
334                );
335                ui.painter().text(
336                    subtitle_pos,
337                    egui::Align2::LEFT_TOP,
338                    subtitle,
339                    egui::TextStyle::Body.resolve(ui.style()),
340                    get_global_color("onSurfaceVariant")
341                );
342            }
343
344            current_y += header_height;
345        }
346
347        let mut response = ui.allocate_response(drawer_rect.size(), Sense::hover());
348
349        // Draw navigation items with unique IDs
350        for (index, item) in self.items.iter().enumerate() {
351            let item_rect = Rect::from_min_size(
352                egui::pos2(drawer_rect.min.x, current_y),
353                Vec2::new(self.width, item_height)
354            );
355
356            // Create unique ID for each item
357            let item_id = self.id.with("item").with(index);
358            let item_response = ui.interact(item_rect, item_id, Sense::click());
359
360            // Draw item background for active state or hover
361            if item.active {
362                let active_color = get_global_color("primary").linear_multiply(0.12);
363                ui.painter().rect_filled(item_rect, CornerRadius::ZERO, active_color);
364            } else if item_response.hovered() {
365                let hover_color = get_global_color("onSurface").linear_multiply(0.08);
366                ui.painter().rect_filled(item_rect, CornerRadius::ZERO, hover_color);
367            }
368
369            let mut current_x = item_rect.min.x + 16.0;
370
371            // Draw icon if present
372            if let Some(_icon) = &item.icon {
373                // Draw a simple placeholder for the icon (circle)
374                let icon_center = egui::pos2(current_x + 12.0, current_y + item_height / 2.0);
375                let icon_color = if item.active {
376                    get_global_color("primary")
377                } else {
378                    get_global_color("onSurfaceVariant")
379                };
380                
381                ui.painter().circle_filled(icon_center, 10.0, icon_color);
382                current_x += 40.0; // Icon width + spacing
383            }
384
385            // Draw item text
386            let text_color = if item.active {
387                get_global_color("primary")
388            } else {
389                get_global_color("onSurface")
390            };
391
392            let text_pos = egui::pos2(current_x, current_y + (item_height - ui.text_style_height(&egui::TextStyle::Body)) / 2.0);
393            ui.painter().text(
394                text_pos,
395                egui::Align2::LEFT_TOP,
396                &item.text,
397                egui::TextStyle::Body.resolve(ui.style()),
398                text_color
399            );
400
401            // Handle item click
402            if item_response.clicked() {
403                if let Some(callback) = &item.on_click {
404                    callback();
405                }
406            }
407
408            response = response.union(item_response);
409            current_y += item_height;
410        }
411
412        response
413    }
414}
415
416impl Widget for MaterialDrawer<'_> {
417    fn ui(self, ui: &mut Ui) -> Response {
418        // This implementation is kept for backward compatibility
419        // but the preferred way is to use the show() method
420        self.render_drawer_content(ui)
421    }
422}
423
424/// Convenience function to create a permanent drawer.
425pub fn permanent_drawer(open: &mut bool) -> MaterialDrawer<'_> {
426    MaterialDrawer::new(DrawerVariant::Permanent, open)
427}
428
429/// Convenience function to create a dismissible drawer.
430pub fn dismissible_drawer(open: &mut bool) -> MaterialDrawer<'_> {
431    MaterialDrawer::new(DrawerVariant::Dismissible, open)
432}
433
434/// Convenience function to create a modal drawer.
435pub fn modal_drawer(open: &mut bool) -> MaterialDrawer<'_> {
436    MaterialDrawer::new(DrawerVariant::Modal, open)
437}
438
439// Legacy support - these will be deprecated
440pub fn standard_drawer(open: &mut bool) -> MaterialDrawer<'_> {
441    permanent_drawer(open)
442}