1use crate::theme::get_global_color;
2use egui::{
3 ecolor::Color32,
4 epaint::{CornerRadius, Shadow, Stroke},
5 Rect, Response, Sense, Ui, Vec2, Widget,
6};
7use std::time::{Duration, Instant};
8
9#[derive(Clone, Copy, Debug, PartialEq)]
11pub enum SnackBarBehavior {
12 Fixed,
15 Floating,
18}
19
20#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
36pub struct MaterialSnackbar<'a> {
37 message: String,
38 action_text: Option<String>,
39 action_callback: Option<Box<dyn Fn() + Send + Sync + 'a>>,
40 visible: bool,
41 auto_dismiss: Option<Duration>,
42 show_time: Option<Instant>,
43 position: SnackbarPosition,
44 corner_radius: CornerRadius,
45 elevation: Option<Shadow>,
46 behavior: SnackBarBehavior,
47 width: Option<f32>,
48 margin: Option<Vec2>,
49 show_close_icon: bool,
50 close_icon_color: Option<Color32>,
51 leading_icon: Option<String>,
52 action_overflow_threshold: f32,
53 on_visible: Option<Box<dyn Fn() + Send + Sync + 'a>>,
54}
55
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum SnackbarPosition {
58 Bottom,
59 Top,
60}
61
62impl<'a> MaterialSnackbar<'a> {
63 pub fn new(message: impl Into<String>) -> Self {
75 Self {
76 message: message.into(),
77 action_text: None,
78 action_callback: None,
79 visible: true,
80 auto_dismiss: Some(Duration::from_secs(4)),
81 show_time: None,
82 position: SnackbarPosition::Bottom,
83 corner_radius: CornerRadius::from(4.0), elevation: None,
85 behavior: SnackBarBehavior::Fixed,
86 width: None,
87 margin: None,
88 show_close_icon: false,
89 close_icon_color: None,
90 leading_icon: None,
91 action_overflow_threshold: 0.25,
92 on_visible: None,
93 }
94 }
95
96 pub fn action<F>(mut self, text: impl Into<String>, callback: F) -> Self
110 where
111 F: Fn() + Send + Sync + 'a,
112 {
113 self.action_text = Some(text.into());
114 self.action_callback = Some(Box::new(callback));
115 self
116 }
117
118 pub fn auto_dismiss(mut self, duration: Option<Duration>) -> Self {
138 self.auto_dismiss = duration;
139 self
140 }
141
142 pub fn position(mut self, position: SnackbarPosition) -> Self {
155 self.position = position;
156 self
157 }
158
159 pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
172 self.corner_radius = corner_radius.into();
173 self
174 }
175
176 pub fn elevation(mut self, elevation: impl Into<Shadow>) -> Self {
191 self.elevation = Some(elevation.into());
192 self
193 }
194
195 pub fn behavior(mut self, behavior: SnackBarBehavior) -> Self {
200 self.behavior = behavior;
201 self
202 }
203
204 pub fn width(mut self, width: f32) -> Self {
210 self.width = Some(width);
211 self
212 }
213
214 pub fn margin(mut self, margin: Vec2) -> Self {
220 self.margin = Some(margin);
221 self
222 }
223
224 pub fn show_close_icon(mut self, show: bool) -> Self {
229 self.show_close_icon = show;
230 self
231 }
232
233 pub fn close_icon_color(mut self, color: Color32) -> Self {
238 self.close_icon_color = Some(color);
239 self
240 }
241
242 pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
247 self.leading_icon = Some(icon.into());
248 self
249 }
250
251 pub fn action_overflow_threshold(mut self, threshold: f32) -> Self {
257 self.action_overflow_threshold = threshold.clamp(0.0, 1.0);
258 self
259 }
260
261 pub fn on_visible<F>(mut self, callback: F) -> Self
266 where
267 F: Fn() + Send + Sync + 'a,
268 {
269 self.on_visible = Some(Box::new(callback));
270 self
271 }
272
273 pub fn show_if(mut self, visible: &mut bool) -> Self {
290 self.visible = *visible;
291 self
292 }
293
294 pub fn show_with_offset(
297 mut self,
298 visible: &mut bool,
299 vertical_offset: f32,
300 ) -> MaterialSnackbarWithOffset<'a> {
301 self.visible = *visible;
302 MaterialSnackbarWithOffset {
303 snackbar: self,
304 vertical_offset,
305 }
306 }
307
308 pub fn show(mut self) -> Self {
310 self.visible = true;
311 if self.show_time.is_none() {
312 self.show_time = Some(Instant::now());
313 }
314 self
315 }
316
317 pub fn hide(mut self) -> Self {
319 self.visible = false;
320 self
321 }
322
323 fn get_snackbar_style(&self) -> (Color32, Option<Stroke>) {
324 let bg_color = get_global_color("inverseSurface");
326 (bg_color, None)
327 }
328}
329
330impl Widget for MaterialSnackbar<'_> {
331 fn ui(mut self, ui: &mut Ui) -> Response {
332 if !self.visible {
333 return ui.allocate_response(Vec2::ZERO, Sense::hover());
334 }
335
336 if self.show_time.is_none() {
338 self.show_time = Some(Instant::now());
339 if let Some(on_visible) = &self.on_visible {
341 on_visible();
342 }
343 }
344
345 let should_auto_dismiss =
347 if let (Some(auto_dismiss), Some(show_time)) = (self.auto_dismiss, self.show_time) {
348 show_time.elapsed() >= auto_dismiss
349 } else {
350 false
351 };
352
353 if should_auto_dismiss {
354 return ui.allocate_response(Vec2::ZERO, Sense::hover());
356 }
357
358 let (background_color, border_stroke) = self.get_snackbar_style();
359
360 let MaterialSnackbar {
361 message,
362 action_text,
363 action_callback,
364 visible: _,
365 auto_dismiss: _,
366 show_time: _,
367 position,
368 corner_radius,
369 elevation: _,
370 behavior,
371 width,
372 margin,
373 show_close_icon,
374 close_icon_color,
375 leading_icon,
376 action_overflow_threshold,
377 on_visible: _,
378 } = self;
379
380 let label_text_color = get_global_color("onInverseSurface");
382 let action_text_color = get_global_color("inversePrimary");
383 let default_close_icon_color = get_global_color("onInverseSurface");
384
385 let icon_galley = leading_icon.as_ref().map(|icon| {
387 ui.painter().layout_no_wrap(
388 icon.clone(),
389 egui::FontId::proportional(20.0), label_text_color,
391 )
392 });
393 let icon_width = icon_galley.as_ref().map_or(0.0, |g| g.size().x + 16.0); let action_galley = action_text.as_ref().map(|text| {
397 ui.painter().layout_no_wrap(
398 text.clone(),
399 egui::FontId::proportional(14.0),
400 action_text_color,
401 )
402 });
403
404 let close_icon_width = if show_close_icon { 48.0 } else { 0.0 }; let action_area_width = if action_galley.is_some() {
409 action_galley.as_ref().unwrap().size().x + 64.0
410 } else {
411 0.0
412 };
413
414 let max_message_width = 600.0 - action_area_width - icon_width - close_icon_width;
415
416 let text_galley = ui.painter().layout(
418 message.clone(),
419 egui::FontId::proportional(14.0),
420 label_text_color,
421 max_message_width.max(200.0),
422 );
423
424 let is_floating = behavior == SnackBarBehavior::Floating;
426 let horizontalPadding = if is_floating { 16.0 } else { 24.0 };
427 let label_padding = Vec2::new(horizontalPadding, 14.0);
428 let action_padding = Vec2::new(8.0, 14.0);
429 let action_spacing = if action_text.is_some() { 8.0 } else { 0.0 };
430 let action_width = action_galley.as_ref().map_or(0.0, |g| g.size().x + 32.0);
431
432 let content_width = icon_width
434 + text_galley.size().x
435 + action_width
436 + action_spacing
437 + close_icon_width
438 + label_padding.x
439 + action_padding.x;
440 let min_width = 344.0;
441 let max_width = 672.0;
442
443 let snackbar_width = if let Some(custom_width) = width {
445 if is_floating {
446 custom_width.clamp(min_width, max_width)
447 } else {
448 content_width.max(min_width).min(max_width)
449 }
450 } else {
451 let available_width = ui.available_width().max(min_width + 48.0) - 48.0;
452 content_width
453 .max(min_width)
454 .min(max_width)
455 .min(available_width)
456 .max(min_width)
457 };
458
459 let min_height = 48.0;
461 let text_height = text_galley.size().y;
462 let icon_height = icon_galley.as_ref().map_or(0.0, |g| g.size().y);
463 let action_height = if action_text.is_some() { 36.0 } else { 0.0 };
464 let content_height = text_height.max(action_height).max(icon_height);
465 let snackbar_height = (content_height + label_padding.y * 2.0).max(min_height);
466
467 let snackbar_size = Vec2::new(snackbar_width, snackbar_height);
468
469 let (_allocated_rect, mut response) = ui.allocate_exact_size(snackbar_size, Sense::click());
471
472 let screen_rect = ui.ctx().screen_rect();
474
475 let effective_margin = if is_floating {
477 margin.unwrap_or(Vec2::new(24.0, 16.0))
478 } else {
479 Vec2::ZERO
480 };
481
482 let snackbar_x = if is_floating {
483 (screen_rect.width() - snackbar_size.x).max(0.0) / 2.0
484 } else {
485 0.0
486 };
487
488 let snackbar_y = match position {
489 SnackbarPosition::Bottom => {
490 if is_floating {
491 screen_rect.height() - snackbar_size.y - effective_margin.y - 32.0
492 } else {
493 screen_rect.height() - snackbar_size.y
494 }
495 }
496 SnackbarPosition::Top => {
497 if is_floating {
498 32.0 + effective_margin.y
499 } else {
500 0.0
501 }
502 }
503 };
504
505 let snackbar_pos = egui::pos2(snackbar_x, snackbar_y);
506 let snackbar_rect = Rect::from_min_size(snackbar_pos, snackbar_size);
507
508 let shadow_layers = [
510 (
511 Vec2::new(0.0, 6.0),
512 10.0,
513 Color32::from_rgba_unmultiplied(0, 0, 0, 20),
514 ),
515 (
516 Vec2::new(0.0, 1.0),
517 18.0,
518 Color32::from_rgba_unmultiplied(0, 0, 0, 14),
519 ),
520 (
521 Vec2::new(0.0, 3.0),
522 5.0,
523 Color32::from_rgba_unmultiplied(0, 0, 0, 12),
524 ),
525 ];
526
527 for (offset, blur_radius, color) in shadow_layers {
528 let shadow_rect = snackbar_rect.translate(offset).expand(blur_radius / 2.0);
529 ui.painter().rect_filled(shadow_rect, corner_radius, color);
530 }
531
532 ui.painter()
534 .rect_filled(snackbar_rect, corner_radius, background_color);
535
536 if let Some(stroke) = border_stroke {
538 ui.painter().rect_stroke(
539 snackbar_rect,
540 corner_radius,
541 stroke,
542 egui::epaint::StrokeKind::Outside,
543 );
544 }
545
546 let text_pos = egui::pos2(
549 snackbar_rect.min.x + label_padding.x,
550 snackbar_rect.min.y + label_padding.y,
551 );
552 ui.painter().galley(text_pos, text_galley, label_text_color);
553
554 let mut action_clicked = false;
556
557 if let (Some(_action_text), Some(action_galley)) =
558 (action_text.as_ref(), action_galley.as_ref())
559 {
560 let action_rect = Rect::from_min_size(
563 egui::pos2(
564 snackbar_rect.max.x - action_width - 8.0, snackbar_rect.min.y + label_padding.y - 6.0, ),
567 Vec2::new(action_width, 36.0),
568 );
569
570 let action_response = ui.interact(action_rect, ui.next_auto_id(), Sense::click());
571
572 if action_response.hovered() {
574 let hover_color = action_text_color.linear_multiply(0.04); ui.painter()
576 .rect_filled(action_rect, CornerRadius::from(4.0), hover_color);
577 }
578 if action_response.is_pointer_button_down_on() {
579 let pressed_color = action_text_color.linear_multiply(0.10); ui.painter()
581 .rect_filled(action_rect, CornerRadius::from(4.0), pressed_color);
582 }
583
584 let action_text_pos = egui::pos2(
586 action_rect.center().x - action_galley.size().x / 2.0,
587 action_rect.center().y - action_galley.size().y / 2.0,
588 );
589 ui.painter()
590 .galley(action_text_pos, action_galley.clone(), action_text_color);
591
592 if action_response.clicked() {
593 if let Some(callback) = action_callback {
594 callback();
595 }
596 action_clicked = true;
597 }
598
599 response = response.union(action_response);
600 }
601
602 if action_clicked {
604 response = response.on_hover_text("Action clicked");
605 }
606
607 if response.clicked() && action_text.is_none() {
609 response = response.on_hover_text("Dismissed");
610 }
611
612 response
613 }
614}
615
616#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
618pub struct MaterialSnackbarWithOffset<'a> {
619 snackbar: MaterialSnackbar<'a>,
620 vertical_offset: f32,
621}
622
623impl Widget for MaterialSnackbarWithOffset<'_> {
624 fn ui(mut self, ui: &mut Ui) -> Response {
625 if !self.snackbar.visible {
626 return ui.allocate_response(Vec2::ZERO, Sense::hover());
627 }
628
629 if self.snackbar.show_time.is_none() {
631 self.snackbar.show_time = Some(Instant::now());
632 }
633
634 let should_auto_dismiss = if let (Some(auto_dismiss), Some(show_time)) =
636 (self.snackbar.auto_dismiss, self.snackbar.show_time)
637 {
638 show_time.elapsed() >= auto_dismiss
639 } else {
640 false
641 };
642
643 if should_auto_dismiss {
644 return ui.allocate_response(Vec2::ZERO, Sense::hover());
646 }
647
648 let (background_color, border_stroke) = self.snackbar.get_snackbar_style();
649
650 let MaterialSnackbar {
651 message,
652 action_text,
653 action_callback,
654 visible: _,
655 auto_dismiss: _,
656 show_time: _,
657 position,
658 corner_radius,
659 elevation: _,
660 behavior,
661 width,
662 margin,
663 show_close_icon,
664 close_icon_color,
665 leading_icon,
666 action_overflow_threshold,
667 on_visible: _,
668 } = self.snackbar;
669
670 let label_text_color = get_global_color("onInverseSurface");
672 let action_text_color = get_global_color("inversePrimary");
673 let default_close_icon_color = get_global_color("onInverseSurface");
674
675 let icon_galley = leading_icon.as_ref().map(|icon| {
677 ui.painter().layout_no_wrap(
678 icon.clone(),
679 egui::FontId::proportional(20.0), label_text_color,
681 )
682 });
683 let icon_width = icon_galley.as_ref().map_or(0.0, |g| g.size().x + 16.0); let action_galley = action_text.as_ref().map(|text| {
687 ui.painter().layout_no_wrap(
688 text.clone(),
689 egui::FontId::proportional(14.0),
690 action_text_color,
691 )
692 });
693
694 let close_icon_width = if show_close_icon { 48.0 } else { 0.0 }; let action_area_width = if action_galley.is_some() {
699 action_galley.as_ref().unwrap().size().x + 64.0
700 } else {
701 0.0
702 };
703
704 let max_message_width = 600.0 - action_area_width - icon_width - close_icon_width;
705
706 let text_galley = ui.painter().layout(
708 message.clone(),
709 egui::FontId::proportional(14.0),
710 label_text_color,
711 max_message_width.max(200.0),
712 );
713
714 let is_floating = behavior == SnackBarBehavior::Floating;
716 let horizontalPadding = if is_floating { 16.0 } else { 24.0 };
717 let label_padding = Vec2::new(horizontalPadding, 14.0);
718 let action_padding = Vec2::new(8.0, 14.0);
719 let action_spacing = if action_text.is_some() { 8.0 } else { 0.0 };
720 let action_width = action_galley.as_ref().map_or(0.0, |g| g.size().x + 32.0);
721
722 let content_width = icon_width
724 + text_galley.size().x
725 + action_width
726 + action_spacing
727 + close_icon_width
728 + label_padding.x
729 + action_padding.x;
730 let min_width = 344.0;
731 let max_width = 672.0;
732
733 let snackbar_width = if let Some(custom_width) = width {
735 if is_floating {
736 custom_width.clamp(min_width, max_width)
737 } else {
738 content_width.max(min_width).min(max_width)
739 }
740 } else {
741 let available_width = ui.available_width().max(min_width + 48.0) - 48.0;
742 content_width
743 .max(min_width)
744 .min(max_width)
745 .min(available_width)
746 .max(min_width)
747 };
748
749 let min_height = 48.0;
751 let text_height = text_galley.size().y;
752 let icon_height = icon_galley.as_ref().map_or(0.0, |g| g.size().y);
753 let action_height = if action_text.is_some() { 36.0 } else { 0.0 };
754 let content_height = text_height.max(action_height).max(icon_height);
755 let snackbar_height = (content_height + label_padding.y * 2.0).max(min_height);
756
757 let snackbar_size = Vec2::new(snackbar_width, snackbar_height);
758
759 let (_allocated_rect, mut response) = ui.allocate_exact_size(snackbar_size, Sense::click());
761
762 let screen_rect = ui.ctx().screen_rect();
764
765 let effective_margin = if is_floating {
767 margin.unwrap_or(Vec2::new(24.0, 16.0))
768 } else {
769 Vec2::ZERO
770 };
771
772 let snackbar_x = if is_floating {
773 (screen_rect.width() - snackbar_size.x).max(0.0) / 2.0
774 } else {
775 0.0
776 };
777
778 let snackbar_y = match position {
779 SnackbarPosition::Bottom => {
780 if is_floating {
781 screen_rect.height() - snackbar_size.y - effective_margin.y - 32.0 - self.vertical_offset
782 } else {
783 screen_rect.height() - snackbar_size.y - self.vertical_offset
784 }
785 }
786 SnackbarPosition::Top => {
787 if is_floating {
788 32.0 + effective_margin.y + self.vertical_offset
789 } else {
790 self.vertical_offset
791 }
792 }
793 };
794
795 let snackbar_pos = egui::pos2(snackbar_x, snackbar_y);
796 let snackbar_rect = Rect::from_min_size(snackbar_pos, snackbar_size);
797
798 let shadow_layers = [
800 (
801 Vec2::new(0.0, 6.0),
802 10.0,
803 Color32::from_rgba_unmultiplied(0, 0, 0, 20),
804 ),
805 (
806 Vec2::new(0.0, 1.0),
807 18.0,
808 Color32::from_rgba_unmultiplied(0, 0, 0, 14),
809 ),
810 (
811 Vec2::new(0.0, 3.0),
812 5.0,
813 Color32::from_rgba_unmultiplied(0, 0, 0, 12),
814 ),
815 ];
816
817 for (offset, blur_radius, color) in shadow_layers {
818 let shadow_rect = snackbar_rect.translate(offset).expand(blur_radius / 2.0);
819 ui.painter().rect_filled(shadow_rect, corner_radius, color);
820 }
821
822 ui.painter()
824 .rect_filled(snackbar_rect, corner_radius, background_color);
825
826 if let Some(stroke) = border_stroke {
828 ui.painter().rect_stroke(
829 snackbar_rect,
830 corner_radius,
831 stroke,
832 egui::epaint::StrokeKind::Outside,
833 );
834 }
835
836 let mut current_x = snackbar_rect.min.x + label_padding.x;
838
839 if let (Some(_icon_text), Some(icon_galley)) = (leading_icon.as_ref(), icon_galley.as_ref())
841 {
842 let icon_pos = egui::pos2(
843 current_x,
844 snackbar_rect.center().y - icon_galley.size().y / 2.0,
845 );
846 ui.painter().galley(icon_pos, icon_galley.clone(), label_text_color);
847 current_x += icon_galley.size().x + 16.0; }
849
850 let text_pos = egui::pos2(current_x, snackbar_rect.min.y + label_padding.y);
852 ui.painter().galley(text_pos, text_galley.clone(), label_text_color);
853
854 let action_and_icon_width = action_width + close_icon_width;
856 let will_overflow_action =
857 action_and_icon_width / snackbar_width > action_overflow_threshold;
858
859 let mut action_clicked = false;
861
862 if let (Some(_action_text), Some(action_galley)) =
863 (action_text.as_ref(), action_galley.as_ref())
864 {
865 let action_rect = if will_overflow_action {
866 Rect::from_min_size(
868 egui::pos2(
869 snackbar_rect.max.x - action_width - close_icon_width - 8.0,
870 snackbar_rect.min.y + label_padding.y + text_galley.size().y + 8.0,
871 ),
872 Vec2::new(action_width, 36.0),
873 )
874 } else {
875 Rect::from_min_size(
877 egui::pos2(
878 snackbar_rect.max.x - action_width - close_icon_width - 8.0,
879 snackbar_rect.min.y + label_padding.y - 6.0,
880 ),
881 Vec2::new(action_width, 36.0),
882 )
883 };
884
885 let action_response = ui.interact(action_rect, ui.next_auto_id(), Sense::click());
886
887 if action_response.hovered() {
889 let hover_color = action_text_color.linear_multiply(0.08);
890 ui.painter()
891 .rect_filled(action_rect, CornerRadius::from(4.0), hover_color);
892 }
893 if action_response.is_pointer_button_down_on() {
894 let pressed_color = action_text_color.linear_multiply(0.12);
895 ui.painter()
896 .rect_filled(action_rect, CornerRadius::from(4.0), pressed_color);
897 }
898
899 let action_text_pos = egui::pos2(
901 action_rect.center().x - action_galley.size().x / 2.0,
902 action_rect.center().y - action_galley.size().y / 2.0,
903 );
904 ui.painter()
905 .galley(action_text_pos, action_galley.clone(), action_text_color);
906
907 if action_response.clicked() {
908 if let Some(callback) = action_callback {
909 callback();
910 }
911 action_clicked = true;
912 }
913
914 response = response.union(action_response);
915 }
916
917 let mut close_clicked = false;
919 if show_close_icon {
920 let close_icon_color = close_icon_color.unwrap_or(default_close_icon_color);
921
922 let close_rect = Rect::from_min_size(
923 egui::pos2(
924 snackbar_rect.max.x - 40.0,
925 snackbar_rect.center().y - 20.0,
926 ),
927 Vec2::new(40.0, 40.0),
928 );
929
930 let close_response = ui.interact(close_rect, ui.next_auto_id(), Sense::click());
931
932 if close_response.hovered() {
934 let hover_color = close_icon_color.linear_multiply(0.08);
935 ui.painter()
936 .circle_filled(close_rect.center(), 20.0, hover_color);
937 }
938 if close_response.is_pointer_button_down_on() {
939 let pressed_color = close_icon_color.linear_multiply(0.12);
940 ui.painter()
941 .circle_filled(close_rect.center(), 20.0, pressed_color);
942 }
943
944 let icon_size = 16.0;
946 let center = close_rect.center();
947 ui.painter().line_segment(
948 [
949 egui::pos2(center.x - icon_size / 2.0, center.y - icon_size / 2.0),
950 egui::pos2(center.x + icon_size / 2.0, center.y + icon_size / 2.0),
951 ],
952 Stroke::new(2.0, close_icon_color),
953 );
954 ui.painter().line_segment(
955 [
956 egui::pos2(center.x + icon_size / 2.0, center.y - icon_size / 2.0),
957 egui::pos2(center.x - icon_size / 2.0, center.y + icon_size / 2.0),
958 ],
959 Stroke::new(2.0, close_icon_color),
960 );
961
962 if close_response.clicked() {
963 close_clicked = true;
964 }
965
966 response = response.union(close_response);
967 }
968
969 if action_clicked || close_clicked {
971 response = response.on_hover_text("Snackbar dismissed");
972 }
973
974 response
975 }
976}
977
978pub fn snackbar(message: impl Into<String>) -> MaterialSnackbar<'static> {
980 MaterialSnackbar::new(message)
981}
982
983pub fn snackbar_with_action<F>(
985 message: impl Into<String>,
986 action_text: impl Into<String>,
987 callback: F,
988) -> MaterialSnackbar<'static>
989where
990 F: Fn() + Send + Sync + 'static,
991{
992 MaterialSnackbar::new(message).action(action_text, callback)
993}