Skip to main content

egui_material3/
snackbar.rs

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/// Defines where a SnackBar should appear and how its location should be adjusted.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub enum SnackBarBehavior {
12    /// Fixes the SnackBar at the bottom of the screen.
13    /// The snackbar will have no margin and appear directly at the bottom.
14    Fixed,
15    /// The snackbar will float above the bottom with margins.
16    /// This allows for custom width and spacing from screen edges.
17    Floating,
18}
19
20/// Material Design snackbar component.
21///
22/// Snackbars provide brief messages about app processes at the bottom of the screen.
23/// They inform users of a process that an app has performed or will perform.
24///
25/// ```
26/// # egui::__run_test_ui(|ui| {
27/// let mut snackbar_visible = true;
28/// let mut snackbar = MaterialSnackbar::new("File deleted successfully")
29///     .action("Undo", || println!("Undo clicked!"))
30///     .show_if(&mut snackbar_visible);
31///
32/// ui.add(snackbar);
33/// # });
34/// ```
35#[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    /// Create a new snackbar with a message.
64    ///
65    /// # Arguments
66    /// * `message` - The message text to display in the snackbar
67    ///
68    /// # Example
69    /// ```rust
70    /// # egui::__run_test_ui(|ui| {
71    /// let snackbar = MaterialSnackbar::new("File saved successfully");
72    /// # });
73    /// ```
74    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), // Material Design small shape radius
84            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    /// Add an action button to the snackbar.
97    ///
98    /// # Arguments
99    /// * `text` - Text label for the action button
100    /// * `callback` - Function to execute when the button is clicked
101    ///
102    /// # Example
103    /// ```rust
104    /// # egui::__run_test_ui(|ui| {
105    /// let snackbar = MaterialSnackbar::new("File deleted")
106    ///     .action("Undo", || println!("Undo action performed"));
107    /// # });
108    /// ```
109    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    /// Set auto-dismiss duration. Set to None to disable auto-dismiss.
119    ///
120    /// # Arguments
121    /// * `duration` - How long to show the snackbar before auto-dismissing.
122    ///                Use `None` to disable auto-dismiss.
123    ///
124    /// # Example
125    /// ```rust
126    /// use std::time::Duration;
127    /// # egui::__run_test_ui(|ui| {
128    /// // Auto-dismiss after 6 seconds
129    /// let snackbar = MaterialSnackbar::new("Custom timeout")
130    ///     .auto_dismiss(Some(Duration::from_secs(6)));
131    ///
132    /// // Never auto-dismiss
133    /// let persistent = MaterialSnackbar::new("Persistent message")
134    ///     .auto_dismiss(None);
135    /// # });
136    /// ```
137    pub fn auto_dismiss(mut self, duration: Option<Duration>) -> Self {
138        self.auto_dismiss = duration;
139        self
140    }
141
142    /// Set the position of the snackbar.
143    ///
144    /// # Arguments
145    /// * `position` - Where to position the snackbar (Bottom or Top)
146    ///
147    /// # Example
148    /// ```rust
149    /// # egui::__run_test_ui(|ui| {
150    /// let snackbar = MaterialSnackbar::new("Top notification")
151    ///     .position(SnackbarPosition::Top);
152    /// # });
153    /// ```
154    pub fn position(mut self, position: SnackbarPosition) -> Self {
155        self.position = position;
156        self
157    }
158
159    /// Set corner radius for rounded corners.
160    ///
161    /// # Arguments
162    /// * `corner_radius` - The corner radius value or CornerRadius struct
163    ///
164    /// # Example
165    /// ```rust
166    /// # egui::__run_test_ui(|ui| {
167    /// let snackbar = MaterialSnackbar::new("Rounded snackbar")
168    ///     .corner_radius(8.0);
169    /// # });
170    /// ```
171    pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
172        self.corner_radius = corner_radius.into();
173        self
174    }
175
176    /// Set elevation shadow for the snackbar.
177    ///
178    /// # Arguments
179    /// * `elevation` - Shadow configuration for elevation effect
180    ///
181    /// # Example
182    /// ```rust
183    /// # egui::__run_test_ui(|ui| {
184    /// use egui::epaint::Shadow;
185    /// let shadow = Shadow::small_dark();
186    /// let snackbar = MaterialSnackbar::new("Elevated snackbar")
187    ///     .elevation(shadow);
188    /// # });
189    /// ```
190    pub fn elevation(mut self, elevation: impl Into<Shadow>) -> Self {
191        self.elevation = Some(elevation.into());
192        self
193    }
194
195    /// Set the behavior of the snackbar (Fixed or Floating).
196    ///
197    /// # Arguments
198    /// * `behavior` - SnackBarBehavior::Fixed or SnackBarBehavior::Floating
199    pub fn behavior(mut self, behavior: SnackBarBehavior) -> Self {
200        self.behavior = behavior;
201        self
202    }
203
204    /// Set custom width for floating snackbar.
205    /// Only applies when behavior is SnackBarBehavior::Floating.
206    ///
207    /// # Arguments
208    /// * `width` - Custom width in pixels
209    pub fn width(mut self, width: f32) -> Self {
210        self.width = Some(width);
211        self
212    }
213
214    /// Set margin/inset padding for floating snackbar.
215    /// Only applies when behavior is SnackBarBehavior::Floating.
216    ///
217    /// # Arguments
218    /// * `margin` - Margin as Vec2 (horizontal, vertical)
219    pub fn margin(mut self, margin: Vec2) -> Self {
220        self.margin = Some(margin);
221        self
222    }
223
224    /// Show a close icon button.
225    ///
226    /// # Arguments
227    /// * `show` - Whether to show the close icon
228    pub fn show_close_icon(mut self, show: bool) -> Self {
229        self.show_close_icon = show;
230        self
231    }
232
233    /// Set the color of the close icon.
234    ///
235    /// # Arguments
236    /// * `color` - Color for the close icon
237    pub fn close_icon_color(mut self, color: Color32) -> Self {
238        self.close_icon_color = Some(color);
239        self
240    }
241
242    /// Add a leading icon to the snackbar.
243    ///
244    /// # Arguments
245    /// * `icon` - Icon text/emoji to display before the message
246    pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
247        self.leading_icon = Some(icon.into());
248        self
249    }
250
251    /// Set the action overflow threshold.
252    /// When action width exceeds this fraction of total width, it moves to a new line.
253    ///
254    /// # Arguments
255    /// * `threshold` - Value between 0.0 and 1.0 (default: 0.25)
256    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    /// Set a callback to be called when the snackbar first becomes visible.
262    ///
263    /// # Arguments
264    /// * `callback` - Function to execute when snackbar is first shown
265    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    /// Show the snackbar only if the condition is true.
274    ///
275    /// This method manages the visibility state properly and is useful for
276    /// toggling snackbar visibility based on application state.
277    ///
278    /// # Arguments
279    /// * `visible` - Mutable reference to a boolean controlling visibility
280    ///
281    /// # Example
282    /// ```rust
283    /// # egui::__run_test_ui(|ui| {
284    /// let mut show_notification = true;
285    /// let snackbar = MaterialSnackbar::new("Conditional message")
286    ///     .show_if(&mut show_notification);
287    /// # });
288    /// ```
289    pub fn show_if(mut self, visible: &mut bool) -> Self {
290        self.visible = *visible;
291        self
292    }
293
294    /// Show the snackbar with a vertical offset for stacking.
295    /// This method is used by snackbar_window.rs to manage multiple snackbars.
296    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    /// Show the snackbar and set up auto-dismiss.
309    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    /// Hide the snackbar.
318    pub fn hide(mut self) -> Self {
319        self.visible = false;
320        self
321    }
322
323    fn get_snackbar_style(&self) -> (Color32, Option<Stroke>) {
324        // Material 3 design tokens: use inverseSurface
325        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        // Initialize show time when first rendered
337        if self.show_time.is_none() {
338            self.show_time = Some(Instant::now());
339            // Call onVisible callback
340            if let Some(on_visible) = &self.on_visible {
341                on_visible();
342            }
343        }
344
345        // Check auto-dismiss
346        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 empty response if auto-dismissed
355            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        // Material 3 design tokens
381        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        // Calculate leading icon size if present
386        let icon_galley = leading_icon.as_ref().map(|icon| {
387            ui.painter().layout_no_wrap(
388                icon.clone(),
389                egui::FontId::proportional(20.0), // Larger for icons
390                label_text_color,
391            )
392        });
393        let icon_width = icon_galley.as_ref().map_or(0.0, |g| g.size().x + 16.0); // icon + spacing
394
395        // Calculate action button size
396        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        // Calculate close icon size
405        let close_icon_width = if show_close_icon { 48.0 } else { 0.0 }; // 24px icon + padding
406
407        // Calculate available width for message
408        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        // Calculate message text with width constraint
417        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        // Material Design padding
425        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        // Calculate width following Material Design constraints
433        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        // Apply custom width if specified (floating only)
444        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        // Calculate dynamic height
460        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        // Allocate space
470        let (_allocated_rect, mut response) = ui.allocate_exact_size(snackbar_size, Sense::click());
471
472        // Calculate position
473        let screen_rect = ui.ctx().screen_rect();
474        
475        // Apply margin for floating behavior
476        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        // Draw Material Design elevation 6dp shadow
509        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        // Draw snackbar background
533        ui.painter()
534            .rect_filled(snackbar_rect, corner_radius, background_color);
535
536        // Draw border if present
537        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        // Draw message text with proper Material Design positioning
547        // For multi-line text, align to the top with proper padding
548        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        // Handle action button if present
555        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            // Material Design action button positioning (right-aligned with proper spacing)
561            // Position action button at top-right, aligned with text baseline
562            let action_rect = Rect::from_min_size(
563                egui::pos2(
564                    snackbar_rect.max.x - action_width - 8.0, // 8px right margin
565                    snackbar_rect.min.y + label_padding.y - 6.0, // Align with text, slight adjustment
566                ),
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            // Material Design state layers for action button
573            if action_response.hovered() {
574                let hover_color = action_text_color.linear_multiply(0.04); // Material hover opacity
575                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); // Material pressed opacity
580                ui.painter()
581                    .rect_filled(action_rect, CornerRadius::from(4.0), pressed_color);
582            }
583
584            // Action text centered in button
585            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        // Update response state
603        if action_clicked {
604            response = response.on_hover_text("Action clicked");
605        }
606
607        // Allow clicking outside action to dismiss (only for basic snackbars)
608        if response.clicked() && action_text.is_none() {
609            response = response.on_hover_text("Dismissed");
610        }
611
612        response
613    }
614}
615
616/// A wrapper for MaterialSnackbar that includes vertical offset for stacking
617#[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        // Initialize show time when first rendered
630        if self.snackbar.show_time.is_none() {
631            self.snackbar.show_time = Some(Instant::now());
632        }
633
634        // Check auto-dismiss
635        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 empty response if auto-dismissed
645            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        // Material 3 design tokens
671        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        // Calculate leading icon size if present
676        let icon_galley = leading_icon.as_ref().map(|icon| {
677            ui.painter().layout_no_wrap(
678                icon.clone(),
679                egui::FontId::proportional(20.0), // Larger for icons
680                label_text_color,
681            )
682        });
683        let icon_width = icon_galley.as_ref().map_or(0.0, |g| g.size().x + 16.0); // icon + spacing
684
685        // Calculate action button size
686        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        // Calculate close icon size
695        let close_icon_width = if show_close_icon { 48.0 } else { 0.0 }; // 24px icon + padding
696
697        // Calculate available width for message
698        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        // Calculate message text with width constraint
707        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        // Material Design padding
715        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        // Calculate width following Material Design constraints
723        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        // Apply custom width if specified (floating only)
734        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        // Calculate dynamic height
750        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        // Allocate space
760        let (_allocated_rect, mut response) = ui.allocate_exact_size(snackbar_size, Sense::click());
761
762        // Calculate position with vertical offset for stacking
763        let screen_rect = ui.ctx().screen_rect();
764        
765        // Apply margin for floating behavior
766        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        // Draw Material Design elevation 6dp shadow
799        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        // Draw snackbar background
823        ui.painter()
824            .rect_filled(snackbar_rect, corner_radius, background_color);
825
826        // Draw border if present
827        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        // Track current x position for content layout
837        let mut current_x = snackbar_rect.min.x + label_padding.x;
838
839        // Draw leading icon if present
840        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; // icon + spacing
848        }
849
850        // Draw message text
851        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        // Calculate action and close icon area width
855        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        // Handle action button if present
860        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                // Action overflows to new line
867                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                // Action stays on same line
876                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            // Material Design state layers for action button
888            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            // Action text centered in button
900            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        // Handle close icon if present
918        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            // State layer for close button
933            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            // Draw X icon
945            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        // Update response state
970        if action_clicked || close_clicked {
971            response = response.on_hover_text("Snackbar dismissed");
972        }
973
974        response
975    }
976}
977
978/// Convenience function to create a simple snackbar.
979pub fn snackbar(message: impl Into<String>) -> MaterialSnackbar<'static> {
980    MaterialSnackbar::new(message)
981}
982
983/// Convenience function to create a snackbar with an action.
984pub 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}