1use crate::theme::get_global_color;
2use egui::{
3 ecolor::Color32,
4 epaint::{CornerRadius, Stroke},
5 pos2, Area, Id, Order, Rect, Response, Sense, SidePanel, Ui, Vec2, Widget,
6};
7
8#[derive(Clone, Copy, Debug, PartialEq)]
10pub enum DrawerVariant {
11 Permanent,
12 Dismissible,
13 Modal,
14}
15
16#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum DrawerAlignment {
19 Start,
21 End,
23}
24
25#[derive(Clone, Debug)]
27pub struct DrawerThemeData {
28 pub background_color: Option<Color32>,
29 pub scrim_color: Option<Color32>,
30 pub elevation: Option<f32>,
31 pub shadow_color: Option<Color32>,
32 pub surface_tint_color: Option<Color32>,
33 pub shape: Option<CornerRadius>,
34 pub end_shape: Option<CornerRadius>,
35 pub width: Option<f32>,
36 pub clip_behavior: Option<bool>,
37}
38
39impl Default for DrawerThemeData {
40 fn default() -> Self {
41 Self {
42 background_color: None,
43 scrim_color: None,
44 elevation: None,
45 shadow_color: None,
46 surface_tint_color: None,
47 shape: None,
48 end_shape: None,
49 width: None,
50 clip_behavior: None,
51 }
52 }
53}
54
55impl DrawerThemeData {
56 pub fn material3_defaults() -> Self {
58 Self {
59 background_color: Some(get_global_color("surfaceContainerLow")),
60 scrim_color: Some(Color32::from_rgba_unmultiplied(0, 0, 0, 138)),
61 elevation: Some(1.0),
62 shadow_color: Some(Color32::TRANSPARENT),
63 surface_tint_color: Some(Color32::TRANSPARENT),
64 shape: Some(CornerRadius::same(16)),
65 end_shape: Some(CornerRadius::same(16)),
66 width: Some(360.0),
67 clip_behavior: Some(true),
68 }
69 }
70
71 pub fn material2_defaults() -> Self {
73 Self {
74 background_color: Some(get_global_color("surface")),
75 scrim_color: Some(Color32::from_rgba_unmultiplied(0, 0, 0, 138)),
76 elevation: Some(16.0),
77 shadow_color: None,
78 surface_tint_color: None,
79 shape: Some(CornerRadius::ZERO),
80 end_shape: Some(CornerRadius::ZERO),
81 width: Some(304.0),
82 clip_behavior: Some(true),
83 }
84 }
85}
86
87pub struct DrawerHeader {
91 decoration_color: Option<Color32>,
92 margin: f32,
93 padding: Vec2,
94 height: f32,
95 title: Option<String>,
96 subtitle: Option<String>,
97}
98
99impl Default for DrawerHeader {
100 fn default() -> Self {
101 Self {
102 decoration_color: None,
103 margin: 8.0,
104 padding: Vec2::new(16.0, 16.0),
105 height: 160.0,
106 title: None,
107 subtitle: None,
108 }
109 }
110}
111
112impl DrawerHeader {
113 pub fn new() -> Self {
114 Self::default()
115 }
116
117 pub fn decoration_color(mut self, color: Color32) -> Self {
118 self.decoration_color = Some(color);
119 self
120 }
121
122 pub fn margin(mut self, margin: f32) -> Self {
123 self.margin = margin;
124 self
125 }
126
127 pub fn padding(mut self, padding: Vec2) -> Self {
128 self.padding = padding;
129 self
130 }
131
132 pub fn height(mut self, height: f32) -> Self {
133 self.height = height;
134 self
135 }
136
137 pub fn title(mut self, title: impl Into<String>) -> Self {
138 self.title = Some(title.into());
139 self
140 }
141
142 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
143 self.subtitle = Some(subtitle.into());
144 self
145 }
146
147 pub fn show(self, ui: &mut Ui) -> Response {
148 let rect = ui.allocate_space(Vec2::new(ui.available_width(), self.height + self.margin)).1;
149
150 let header_rect = Rect::from_min_size(
151 rect.min + Vec2::new(0.0, 0.0),
152 Vec2::new(rect.width(), self.height),
153 );
154
155 let bg_color = self.decoration_color.unwrap_or_else(|| get_global_color("surfaceContainerHigh"));
157 ui.painter().rect_filled(header_rect, CornerRadius::ZERO, bg_color);
158
159 let border_y = header_rect.max.y;
161 ui.painter().line_segment(
162 [egui::pos2(header_rect.min.x, border_y), egui::pos2(header_rect.max.x, border_y)],
163 Stroke::new(1.0, get_global_color("outlineVariant")),
164 );
165
166 let content_rect = header_rect.shrink2(self.padding);
168
169 if let Some(title) = &self.title {
170 let title_pos = egui::pos2(content_rect.min.x, content_rect.min.y);
171 ui.painter().text(
172 title_pos,
173 egui::Align2::LEFT_TOP,
174 title,
175 egui::FontId::proportional(22.0),
176 get_global_color("onSurface"),
177 );
178 }
179
180 if let Some(subtitle) = &self.subtitle {
181 let subtitle_pos = egui::pos2(content_rect.min.x, content_rect.min.y + 32.0);
182 ui.painter().text(
183 subtitle_pos,
184 egui::Align2::LEFT_TOP,
185 subtitle,
186 egui::FontId::proportional(14.0),
187 get_global_color("onSurfaceVariant"),
188 );
189 }
190
191 ui.interact(rect, ui.id().with("drawer_header"), Sense::hover())
192 }
193}
194
195pub struct MaterialDrawer<'a> {
214 variant: DrawerVariant,
215 open: &'a mut bool,
216 width: f32,
217 alignment: DrawerAlignment,
218 header_title: Option<String>,
219 header_subtitle: Option<String>,
220 items: Vec<DrawerItem>,
221 sections: Vec<DrawerSection>,
222 corner_radius: CornerRadius,
223 elevation: Option<f32>,
224 theme: DrawerThemeData,
225 enable_drag_gesture: bool,
226 edge_drag_width: Option<f32>,
227 barrier_dismissible: bool,
228 semantic_label: Option<String>,
229 id: Id,
230}
231
232pub struct DrawerSection {
234 pub label: Option<String>,
235 pub items: Vec<DrawerItem>,
236}
237
238pub struct DrawerItem {
240 pub text: String,
241 pub icon: Option<String>,
242 pub active: bool,
243 pub enabled: bool,
244 pub badge: Option<String>,
245 pub on_click: Option<Box<dyn Fn() + Send + Sync>>,
246}
247
248impl DrawerItem {
249 pub fn new(text: impl Into<String>) -> Self {
250 Self {
251 text: text.into(),
252 icon: None,
253 active: false,
254 enabled: true,
255 badge: None,
256 on_click: None,
257 }
258 }
259
260 pub fn icon(mut self, icon: impl Into<String>) -> Self {
261 self.icon = Some(icon.into());
262 self
263 }
264
265 pub fn active(mut self, active: bool) -> Self {
266 self.active = active;
267 self
268 }
269
270 pub fn enabled(mut self, enabled: bool) -> Self {
271 self.enabled = enabled;
272 self
273 }
274
275 pub fn badge(mut self, badge: impl Into<String>) -> Self {
276 self.badge = Some(badge.into());
277 self
278 }
279
280 pub fn on_click<F>(mut self, callback: F) -> Self
281 where
282 F: Fn() + Send + Sync + 'static,
283 {
284 self.on_click = Some(Box::new(callback));
285 self
286 }
287}
288
289impl<'a> MaterialDrawer<'a> {
290 pub fn new(variant: DrawerVariant, open: &'a mut bool) -> Self {
292 let id = Id::new(format!("material_drawer_{:?}", variant));
293 let theme = DrawerThemeData::material3_defaults();
294 let width = theme.width.unwrap_or(360.0);
295 let corner_radius = theme.shape.unwrap_or(CornerRadius::same(16));
296 let elevation = theme.elevation;
297
298 Self {
299 variant,
300 open,
301 width,
302 alignment: DrawerAlignment::Start,
303 header_title: None,
304 header_subtitle: None,
305 items: Vec::new(),
306 sections: Vec::new(),
307 corner_radius,
308 elevation,
309 theme,
310 enable_drag_gesture: true,
311 edge_drag_width: None,
312 barrier_dismissible: true,
313 semantic_label: None,
314 id,
315 }
316 }
317
318 pub fn new_with_id(variant: DrawerVariant, open: &'a mut bool, id: Id) -> Self {
320 let theme = DrawerThemeData::material3_defaults();
321 let width = theme.width.unwrap_or(360.0);
322 let corner_radius = theme.shape.unwrap_or(CornerRadius::same(16));
323 let elevation = theme.elevation;
324
325 Self {
326 variant,
327 open,
328 width,
329 alignment: DrawerAlignment::Start,
330 header_title: None,
331 header_subtitle: None,
332 items: Vec::new(),
333 sections: Vec::new(),
334 corner_radius,
335 elevation,
336 theme,
337 enable_drag_gesture: true,
338 edge_drag_width: None,
339 barrier_dismissible: true,
340 semantic_label: None,
341 id,
342 }
343 }
344
345 pub fn alignment(mut self, alignment: DrawerAlignment) -> Self {
347 self.alignment = alignment;
348 self
349 }
350
351 pub fn width(mut self, width: f32) -> Self {
353 self.width = width;
354 self
355 }
356
357 pub fn theme(mut self, theme: DrawerThemeData) -> Self {
359 if let Some(width) = theme.width {
360 self.width = width;
361 }
362 if let Some(shape) = theme.shape {
363 self.corner_radius = shape;
364 }
365 if let Some(elevation) = theme.elevation {
366 self.elevation = Some(elevation);
367 }
368 self.theme = theme;
369 self
370 }
371
372 pub fn enable_drag_gesture(mut self, enable: bool) -> Self {
374 self.enable_drag_gesture = enable;
375 self
376 }
377
378 pub fn edge_drag_width(mut self, width: f32) -> Self {
380 self.edge_drag_width = Some(width);
381 self
382 }
383
384 pub fn barrier_dismissible(mut self, dismissible: bool) -> Self {
386 self.barrier_dismissible = dismissible;
387 self
388 }
389
390 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
392 self.semantic_label = Some(label.into());
393 self
394 }
395
396 pub fn header(mut self, title: impl Into<String>, subtitle: Option<impl Into<String>>) -> Self {
398 self.header_title = Some(title.into());
399 self.header_subtitle = subtitle.map(|s| s.into());
400 self
401 }
402
403 pub fn item(
405 mut self,
406 text: impl Into<String>,
407 icon: Option<impl Into<String>>,
408 active: bool,
409 ) -> Self {
410 self.items.push(DrawerItem {
411 text: text.into(),
412 icon: icon.map(|i| i.into()),
413 active,
414 enabled: true,
415 badge: None,
416 on_click: None,
417 });
418 self
419 }
420
421 pub fn item_with_callback<F>(
423 mut self,
424 text: impl Into<String>,
425 icon: Option<impl Into<String>>,
426 active: bool,
427 callback: F,
428 ) -> Self
429 where
430 F: Fn() + Send + Sync + 'static,
431 {
432 self.items.push(DrawerItem {
433 text: text.into(),
434 icon: icon.map(|i| i.into()),
435 active,
436 enabled: true,
437 badge: None,
438 on_click: Some(Box::new(callback)),
439 });
440 self
441 }
442
443 pub fn add_item(mut self, item: DrawerItem) -> Self {
445 self.items.push(item);
446 self
447 }
448
449 pub fn section(mut self, label: Option<impl Into<String>>, items: Vec<DrawerItem>) -> Self {
451 self.sections.push(DrawerSection {
452 label: label.map(|l| l.into()),
453 items,
454 });
455 self
456 }
457
458 pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
460 self.corner_radius = corner_radius.into();
461 self
462 }
463
464 pub fn elevation(mut self, elevation: f32) -> Self {
466 self.elevation = Some(elevation);
467 self
468 }
469
470 fn get_drawer_style(&self) -> (Color32, Option<Stroke>, f32) {
471 let background_color = self.theme.background_color
472 .unwrap_or_else(|| get_global_color("surfaceContainerLow"));
473
474 let elevation = self.elevation.unwrap_or(1.0);
475
476 match self.variant {
477 DrawerVariant::Permanent => {
478 let border_color = get_global_color("outlineVariant");
480 (background_color, Some(Stroke::new(1.0, border_color)), elevation)
481 }
482 DrawerVariant::Modal => {
483 (background_color, None, elevation)
485 }
486 DrawerVariant::Dismissible => {
487 let border_color = get_global_color("outlineVariant");
489 (background_color, Some(Stroke::new(1.0, border_color)), elevation)
490 }
491 }
492 }
493
494 pub fn show(self, ctx: &egui::Context) -> Response {
496 match self.variant {
497 DrawerVariant::Permanent => self.show_permanent(ctx),
498 DrawerVariant::Dismissible => self.show_dismissible(ctx),
499 DrawerVariant::Modal => self.show_modal(ctx),
500 }
501 }
502
503 fn show_permanent(self, ctx: &egui::Context) -> Response {
504 SidePanel::left(self.id.with("permanent"))
505 .default_width(self.width)
506 .resizable(false)
507 .show(ctx, |ui| self.render_drawer_content(ui))
508 .response
509 }
510
511 fn show_dismissible(self, ctx: &egui::Context) -> Response {
512 if *self.open {
513 SidePanel::left(self.id.with("dismissible"))
514 .default_width(self.width)
515 .resizable(false)
516 .show(ctx, |ui| self.render_drawer_content(ui))
517 .response
518 } else {
519 Area::new(self.id.with("dismissible_dummy"))
521 .fixed_pos(pos2(-1000.0, -1000.0)) .show(ctx, |ui| ui.allocate_response(Vec2::ZERO, Sense::hover()))
523 .response
524 }
525 }
526
527 fn show_modal(self, ctx: &egui::Context) -> Response {
528 if *self.open {
529 let screen_rect = ctx.screen_rect();
531 let scrim_color = self.theme.scrim_color
532 .unwrap_or(Color32::from_rgba_unmultiplied(0, 0, 0, 138));
533
534 Area::new(self.id.with("modal_scrim"))
535 .order(Order::Background)
536 .show(ctx, |ui| {
537 let scrim_response = ui.allocate_response(screen_rect.size(), Sense::click());
538 ui.painter().rect_filled(
539 screen_rect,
540 CornerRadius::ZERO,
541 scrim_color,
542 );
543
544 if scrim_response.clicked() && self.barrier_dismissible {
546 *self.open = false;
547 }
548 });
549
550 Area::new(self.id.with("modal_drawer"))
552 .order(Order::Foreground)
553 .fixed_pos(pos2(0.0, 0.0))
554 .show(ctx, |ui| {
555 ui.set_width(self.width);
556 ui.set_height(screen_rect.height());
557 self.render_drawer_content(ui)
558 })
559 .response
560 } else {
561 Area::new(self.id.with("modal_dummy"))
563 .fixed_pos(pos2(-1000.0, -1000.0)) .show(ctx, |ui| ui.allocate_response(Vec2::ZERO, Sense::hover()))
565 .response
566 }
567 }
568
569 fn render_drawer_content(self, ui: &mut Ui) -> Response {
570 let (background_color, border_stroke, _elevation) = self.get_drawer_style();
571
572 if matches!(
574 self.variant,
575 DrawerVariant::Dismissible | DrawerVariant::Modal
576 ) {
577 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
578 *self.open = false;
579 }
580 }
581
582 let available_rect = ui.available_rect_before_wrap();
583 let drawer_rect = Rect::from_min_size(
584 available_rect.min,
585 Vec2::new(self.width, available_rect.height()),
586 );
587
588 ui.painter()
590 .rect_filled(drawer_rect, self.corner_radius, background_color);
591
592 if let Some(stroke) = border_stroke {
594 ui.painter().rect_stroke(
595 drawer_rect,
596 self.corner_radius,
597 stroke,
598 egui::epaint::StrokeKind::Outside,
599 );
600 }
601
602 let mut current_y = drawer_rect.min.y;
603 let item_height = 56.0; let section_padding_top = 16.0;
605 let section_padding_bottom = 10.0;
606 let horizontal_padding = 12.0; if let Some(title) = &self.header_title {
610 let header_height = 64.0;
611 let header_rect = Rect::from_min_size(
612 egui::pos2(drawer_rect.min.x, current_y),
613 Vec2::new(self.width, header_height),
614 );
615
616 let title_pos = egui::pos2(header_rect.min.x + 28.0, header_rect.min.y + 16.0);
618 ui.painter().text(
619 title_pos,
620 egui::Align2::LEFT_TOP,
621 title,
622 egui::FontId::proportional(22.0),
623 get_global_color("onSurfaceVariant"),
624 );
625
626 if let Some(subtitle) = &self.header_subtitle {
627 let subtitle_pos = egui::pos2(header_rect.min.x + 28.0, header_rect.min.y + 42.0);
628 ui.painter().text(
629 subtitle_pos,
630 egui::Align2::LEFT_TOP,
631 subtitle,
632 egui::FontId::proportional(14.0),
633 get_global_color("onSurfaceVariant"),
634 );
635 }
636
637 current_y += header_height;
638 }
639
640 let mut response = ui.allocate_response(drawer_rect.size(), Sense::hover());
641
642 if !self.sections.is_empty() {
644 for (section_idx, section) in self.sections.iter().enumerate() {
645 if let Some(label) = §ion.label {
647 current_y += section_padding_top;
648 let label_pos = egui::pos2(drawer_rect.min.x + 28.0, current_y);
649 ui.painter().text(
650 label_pos,
651 egui::Align2::LEFT_TOP,
652 label,
653 egui::FontId::proportional(14.0),
654 get_global_color("onSurfaceVariant"),
655 );
656 current_y += section_padding_bottom + 10.0;
657 }
658
659 for (index, item) in section.items.iter().enumerate() {
661 let item_response = self.render_navigation_item(
662 ui,
663 item,
664 drawer_rect,
665 current_y,
666 item_height,
667 horizontal_padding,
668 self.id.with("section").with(section_idx).with(index),
669 );
670 response = response.union(item_response);
671 current_y += item_height;
672 }
673
674 if section_idx < self.sections.len() - 1 {
676 current_y += 8.0;
677 let divider_y = current_y;
678 ui.painter().line_segment(
679 [
680 egui::pos2(drawer_rect.min.x + 28.0, divider_y),
681 egui::pos2(drawer_rect.max.x - 28.0, divider_y),
682 ],
683 Stroke::new(1.0, get_global_color("outlineVariant")),
684 );
685 current_y += 8.0;
686 }
687 }
688 } else {
689 for (index, item) in self.items.iter().enumerate() {
691 let item_response = self.render_navigation_item(
692 ui,
693 item,
694 drawer_rect,
695 current_y,
696 item_height,
697 horizontal_padding,
698 self.id.with("item").with(index),
699 );
700 response = response.union(item_response);
701 current_y += item_height;
702 }
703 }
704
705 response
706 }
707
708 fn render_navigation_item(
709 &self,
710 ui: &mut Ui,
711 item: &DrawerItem,
712 drawer_rect: Rect,
713 y_pos: f32,
714 item_height: f32,
715 horizontal_padding: f32,
716 item_id: Id,
717 ) -> Response {
718 let item_outer_rect = Rect::from_min_size(
720 egui::pos2(drawer_rect.min.x + horizontal_padding, y_pos),
721 Vec2::new(self.width - horizontal_padding * 2.0, item_height),
722 );
723
724 let item_response = ui.interact(item_outer_rect, item_id, Sense::click());
725
726 if item.active {
728 let indicator_width = item_outer_rect.width();
729 let indicator_height = 32.0;
730 let indicator_y = y_pos + (item_height - indicator_height) / 2.0;
731
732 let indicator_rect = Rect::from_min_size(
733 egui::pos2(item_outer_rect.min.x, indicator_y),
734 Vec2::new(indicator_width, indicator_height),
735 );
736
737 let active_color = get_global_color("secondaryContainer");
738 ui.painter().rect_filled(
739 indicator_rect,
740 CornerRadius::same(16),
741 active_color,
742 );
743 } else if item_response.hovered() && item.enabled {
744 let indicator_width = item_outer_rect.width();
745 let indicator_height = 32.0;
746 let indicator_y = y_pos + (item_height - indicator_height) / 2.0;
747
748 let indicator_rect = Rect::from_min_size(
749 egui::pos2(item_outer_rect.min.x, indicator_y),
750 Vec2::new(indicator_width, indicator_height),
751 );
752
753 let hover_color = get_global_color("onSurface").linear_multiply(0.08);
754 ui.painter().rect_filled(
755 indicator_rect,
756 CornerRadius::same(16),
757 hover_color,
758 );
759 }
760
761 let mut current_x = item_outer_rect.min.x + 16.0;
762
763 if let Some(_icon) = &item.icon {
765 let icon_center = egui::pos2(current_x + 12.0, y_pos + item_height / 2.0);
766 let icon_color = if !item.enabled {
767 get_global_color("onSurface").linear_multiply(0.38)
768 } else if item.active {
769 get_global_color("onSecondaryContainer")
770 } else {
771 get_global_color("onSurfaceVariant")
772 };
773
774 ui.painter().circle_filled(icon_center, 12.0, icon_color);
775 current_x += 40.0;
776 }
777
778 let text_color = if !item.enabled {
780 get_global_color("onSurface").linear_multiply(0.38)
781 } else if item.active {
782 get_global_color("onSecondaryContainer")
783 } else {
784 get_global_color("onSurfaceVariant")
785 };
786
787 let text_pos = egui::pos2(
788 current_x,
789 y_pos + (item_height - 20.0) / 2.0,
790 );
791
792 ui.painter().text(
793 text_pos,
794 egui::Align2::LEFT_CENTER,
795 &item.text,
796 egui::FontId::proportional(14.0),
797 text_color,
798 );
799
800 if let Some(badge) = &item.badge {
802 let badge_x = item_outer_rect.max.x - 40.0;
803 let badge_center = egui::pos2(badge_x, y_pos + item_height / 2.0);
804
805 ui.painter().circle_filled(
807 badge_center,
808 10.0,
809 get_global_color("error"),
810 );
811
812 ui.painter().text(
814 badge_center,
815 egui::Align2::CENTER_CENTER,
816 badge,
817 egui::FontId::proportional(10.0),
818 get_global_color("onError"),
819 );
820 }
821
822 if item_response.clicked() && item.enabled {
824 if let Some(callback) = &item.on_click {
825 callback();
826 }
827 }
828
829 item_response
830 }
831}
832
833impl Widget for MaterialDrawer<'_> {
834 fn ui(self, ui: &mut Ui) -> Response {
835 self.render_drawer_content(ui)
838 }
839}
840
841pub fn permanent_drawer(open: &mut bool) -> MaterialDrawer<'_> {
843 MaterialDrawer::new(DrawerVariant::Permanent, open)
844}
845
846pub fn dismissible_drawer(open: &mut bool) -> MaterialDrawer<'_> {
848 MaterialDrawer::new(DrawerVariant::Dismissible, open)
849}
850
851pub fn modal_drawer(open: &mut bool) -> MaterialDrawer<'_> {
853 MaterialDrawer::new(DrawerVariant::Modal, open)
854}
855
856pub fn standard_drawer(open: &mut bool) -> MaterialDrawer<'_> {
858 permanent_drawer(open)
859}