Skip to main content

egui_material3/
notification.rs

1use crate::theme::get_global_color;
2use crate::material_symbol::material_symbol_text;
3use egui::{
4    ecolor::Color32, pos2, Area, FontId, Id, Order, Rect, Response, Sense, Stroke, Ui, Vec2, Widget,
5};
6use std::time::Duration;
7
8/// Notification alignment position
9#[derive(Clone, Copy, PartialEq, Debug)]
10pub enum NotificationAlign {
11    /// Left-aligned notifications
12    Left,
13    /// Center-aligned notifications
14    Center,
15    /// Right-aligned notifications
16    Right,
17}
18
19/// Material Design notification component.
20///
21/// Notifications display system-style messages to users with support for titles,
22/// subtitles, icons, and close buttons. They follow Material Design 3 specifications.
23///
24/// # Example
25/// ```rust
26/// # egui::__run_test_ui(|ui| {
27/// use egui_material3::{notification, MaterialNotification};
28///
29/// // Simple notification
30/// ui.add(notification()
31///     .title("New Message")
32///     .text("You have a new message from John Doe"));
33///
34/// // Notification with icon and close button
35/// ui.add(notification()
36///     .title("Download Complete")
37///     .subtitle("Your file is ready")
38///     .icon("download")
39///     .closeable(true));
40/// # });
41/// ```
42#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
43pub struct MaterialNotification {
44    /// Main title text
45    title: Option<String>,
46    /// Subtitle text
47    subtitle: Option<String>,
48    /// Main message text
49    text: Option<String>,
50    /// Icon name (Material Symbol)
51    icon: Option<String>,
52    /// Right-aligned text (e.g., timestamp)
53    title_right_text: Option<String>,
54    /// Whether the notification can be closed
55    closeable: bool,
56    /// Whether the notification is currently opened
57    opened: bool,
58    /// Auto-dismiss duration (None means no auto-dismiss)
59    auto_dismiss: Option<Duration>,
60    /// Custom background color
61    bg_color: Option<Color32>,
62    /// Custom width
63    width: Option<f32>,
64    /// Notification alignment
65    align: NotificationAlign,
66}
67
68impl MaterialNotification {
69    /// Create a new notification
70    pub fn new() -> Self {
71        Self {
72            title: None,
73            subtitle: None,
74            text: None,
75            icon: None,
76            title_right_text: None,
77            closeable: false,
78            opened: true,
79            auto_dismiss: None,
80            bg_color: None,
81            width: None,
82            align: NotificationAlign::Center,
83        }
84    }
85
86    /// Set the title
87    pub fn title(mut self, title: impl Into<String>) -> Self {
88        self.title = Some(title.into());
89        self
90    }
91
92    /// Set the subtitle
93    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
94        self.subtitle = Some(subtitle.into());
95        self
96    }
97
98    /// Set the text content
99    pub fn text(mut self, text: impl Into<String>) -> Self {
100        self.text = Some(text.into());
101        self
102    }
103
104    /// Set the icon (Material Symbol name)
105    pub fn icon(mut self, icon: impl Into<String>) -> Self {
106        self.icon = Some(icon.into());
107        self
108    }
109
110    /// Set the right-aligned title text (e.g., timestamp)
111    pub fn title_right_text(mut self, text: impl Into<String>) -> Self {
112        self.title_right_text = Some(text.into());
113        self
114    }
115
116    /// Set whether the notification can be closed
117    pub fn closeable(mut self, closeable: bool) -> Self {
118        self.closeable = closeable;
119        self
120    }
121
122    /// Set whether the notification is opened
123    pub fn opened(mut self, opened: bool) -> Self {
124        self.opened = opened;
125        self
126    }
127
128    /// Set auto-dismiss duration
129    pub fn auto_dismiss(mut self, duration: Duration) -> Self {
130        self.auto_dismiss = Some(duration);
131        self
132    }
133
134    /// Set custom background color
135    pub fn bg_color(mut self, color: Color32) -> Self {
136        self.bg_color = Some(color);
137        self
138    }
139
140    /// Set custom width
141    pub fn width(mut self, width: f32) -> Self {
142        self.width = Some(width);
143        self
144    }
145
146    /// Set notification alignment
147    pub fn align(mut self, align: NotificationAlign) -> Self {
148        self.align = align;
149        self
150    }
151
152    /// Show the notification with a vertical offset for stacking.
153    /// This is useful for displaying multiple notifications.
154    pub fn with_offset(self, offset: f32) -> MaterialNotificationWithOffset {
155        MaterialNotificationWithOffset {
156            notification: self,
157            vertical_offset: offset,
158        }
159    }
160}
161
162/// Notification with vertical offset for stacking
163pub struct MaterialNotificationWithOffset {
164    notification: MaterialNotification,
165    vertical_offset: f32,
166}
167
168impl Default for MaterialNotification {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl Widget for MaterialNotification {
175    fn ui(self, ui: &mut Ui) -> Response {
176        self.ui_with_offset(ui, 0.0)
177    }
178}
179
180impl MaterialNotification {
181    fn ui_with_offset(self, ui: &mut Ui, vertical_offset: f32) -> Response {
182        if !self.opened {
183            return ui.allocate_response(Vec2::ZERO, Sense::hover());
184        }
185
186        // Get Material Design colors
187        let surface_container_highest = get_global_color("surfaceContainerHighest");
188        let on_surface = get_global_color("onSurface");
189        let on_surface_variant = get_global_color("onSurfaceVariant");
190        let outline = get_global_color("outline");
191
192        let bg_color = self.bg_color.unwrap_or(surface_container_highest);
193
194        // Calculate notification width
195        let screen_rect = ui.ctx().screen_rect();
196        let max_width: f32 = 400.0;
197        let width = self.width.unwrap_or(max_width.min(screen_rect.width() - 48.0));
198
199        let padding = 12.0;
200        let content_width = width - padding * 2.0;
201
202        // Reserve space for icon if present
203        let icon_size = 24.0;
204        let icon_margin = 8.0;
205        let has_icon = self.icon.is_some();
206        let text_width = if has_icon {
207            content_width - icon_size - icon_margin
208        } else {
209            content_width
210        };
211
212        // Calculate space for close button
213        let close_button_space = if self.closeable { 40.0 } else { 0.0 };
214        let available_text_width = text_width - close_button_space;
215
216        // Pre-calculate all text layouts to determine height
217        let title_galley = self.title.as_ref().map(|title_text| {
218            ui.painter().layout(
219                title_text.clone(),
220                FontId::proportional(16.0),
221                on_surface,
222                available_text_width - if self.title_right_text.is_some() { 60.0 } else { 0.0 },
223            )
224        });
225
226        let subtitle_galley = self.subtitle.as_ref().map(|subtitle_text| {
227            ui.painter().layout(
228                subtitle_text.clone(),
229                FontId::proportional(14.0),
230                on_surface_variant,
231                available_text_width,
232            )
233        });
234
235        let text_galley = self.text.as_ref().map(|content_text| {
236            ui.painter().layout(
237                content_text.clone(),
238                FontId::proportional(14.0),
239                on_surface_variant,
240                available_text_width,
241            )
242        });
243
244        let right_text_galley = self.title_right_text.as_ref().map(|right_text| {
245            ui.painter().layout_no_wrap(
246                right_text.clone(),
247                FontId::proportional(12.0),
248                on_surface_variant,
249            )
250        });
251
252        // Calculate total height
253        let mut total_height = padding * 2.0;
254        if let Some(ref galley) = title_galley {
255            total_height += galley.size().y + 4.0;
256        }
257        if let Some(ref galley) = subtitle_galley {
258            total_height += galley.size().y + 4.0;
259        }
260        if let Some(ref galley) = text_galley {
261            total_height += galley.size().y;
262        }
263
264        // Position notification based on alignment
265        // Use screen_rect to keep notifications fixed in viewport (not affected by scrolling)
266        // Add 50px to avoid being cropped by window header
267        let screen_rect = ui.ctx().screen_rect();
268        let notification_x = match self.align {
269            NotificationAlign::Left => screen_rect.min.x + 16.0,
270            NotificationAlign::Center => screen_rect.min.x + (screen_rect.width() - width) / 2.0,
271            NotificationAlign::Right => screen_rect.max.x - width - 16.0,
272        };
273        let notification_y = screen_rect.min.y + 16.0 + 50.0 + vertical_offset; // Top margin + header offset + stacking offset
274        let notification_pos = pos2(notification_x, notification_y);
275
276        // Create a unique ID for this notification based on its content
277        let notification_id = Id::new("notification").with(self.title.as_deref().unwrap_or(""))
278            .with(self.text.as_deref().unwrap_or(""))
279            .with(vertical_offset as i32); // Convert f32 to i32 for Hash
280
281        // Use Area to create a floating overlay on top of all content
282        let area_response = Area::new(notification_id)
283            .fixed_pos(notification_pos)
284            .order(Order::Foreground) // Always on top
285            .interactable(true)
286            .show(ui.ctx(), |ui| {
287                // Allocate space for the notification
288                let (rect, mut response) = ui.allocate_exact_size(Vec2::new(width, total_height), Sense::click());
289                let notification_rect = rect;
290
291                // Draw background with rounded corners
292                ui.painter().rect_filled(notification_rect, 12.0, bg_color);
293
294                // Draw border
295                ui.painter().rect_stroke(
296                    notification_rect,
297                    12.0,
298                    Stroke::new(1.0, outline),
299                    egui::epaint::StrokeKind::Outside,
300                );
301
302                // Now draw all content
303                let mut current_y = notification_rect.min.y + padding;
304                let left_margin = notification_rect.min.x + padding;
305                let text_start_x = if has_icon {
306                    left_margin + icon_size + icon_margin
307                } else {
308                    left_margin
309                };
310
311                // Draw icon if present
312                if let Some(icon_name) = &self.icon {
313                    let icon_text = material_symbol_text(icon_name);
314                    let icon_galley = ui.painter().layout_no_wrap(
315                        icon_text.to_string(),
316                        FontId::proportional(icon_size),
317                        on_surface,
318                    );
319                    let icon_pos = pos2(left_margin, current_y);
320                    ui.painter().galley(icon_pos, icon_galley, on_surface);
321                }
322
323                // Draw title and right text
324                if let Some(galley) = title_galley {
325                    let title_pos = pos2(text_start_x, current_y);
326                    ui.painter().galley(title_pos, galley.clone(), on_surface);
327
328                    // Draw right text if present
329                    if let Some(right_galley) = right_text_galley {
330                        let right_pos = pos2(
331                            notification_rect.max.x - padding - close_button_space - right_galley.size().x,
332                            current_y,
333                        );
334                        ui.painter().galley(right_pos, right_galley, on_surface_variant);
335                    }
336
337                    current_y += galley.size().y + 4.0;
338                }
339
340                // Draw subtitle
341                if let Some(galley) = subtitle_galley {
342                    let subtitle_pos = pos2(text_start_x, current_y);
343                    ui.painter().galley(subtitle_pos, galley.clone(), on_surface_variant);
344                    current_y += galley.size().y + 4.0;
345                }
346
347                // Draw text content
348                if let Some(galley) = text_galley {
349                    let text_pos = pos2(text_start_x, current_y);
350                    ui.painter().galley(text_pos, galley, on_surface_variant);
351                }
352
353                // Draw close button if closeable
354                let mut close_clicked = false;
355                if self.closeable {
356                    let close_button_pos = pos2(
357                        notification_rect.max.x - padding - 24.0,
358                        notification_rect.min.y + padding,
359                    );
360                    let close_icon = material_symbol_text("close");
361                    let close_galley = ui.painter().layout_no_wrap(
362                        close_icon.to_string(),
363                        FontId::proportional(20.0),
364                        on_surface_variant,
365                    );
366
367                    let close_rect = Rect::from_center_size(
368                        pos2(close_button_pos.x + 12.0, close_button_pos.y + 12.0),
369                        Vec2::new(24.0, 24.0),
370                    );
371
372                    let close_response = ui.interact(close_rect, response.id.with("close"), Sense::click());
373
374                    if close_response.hovered() {
375                        ui.painter().circle_filled(close_rect.center(), 12.0, on_surface_variant.linear_multiply(0.1));
376                    }
377
378                    ui.painter().galley(close_button_pos, close_galley, on_surface_variant);
379
380                    if close_response.clicked() {
381                        close_clicked = true;
382                        response.mark_changed();
383                    }
384                }
385
386                // If notification was clicked (but not the close button), mark as clicked
387                if response.clicked() && !close_clicked {
388                    // Already marked by the response
389                } else if close_clicked {
390                    // Close button was clicked, already marked as changed
391                }
392
393                response
394            });
395
396        area_response.inner
397    }
398}
399
400impl Widget for MaterialNotificationWithOffset {
401    fn ui(self, ui: &mut Ui) -> Response {
402        self.notification.ui_with_offset(ui, self.vertical_offset)
403    }
404}
405
406/// Convenience function to create a notification
407///
408/// # Example
409/// ```rust
410/// # egui::__run_test_ui(|ui| {
411/// use egui_material3::notification;
412///
413/// ui.add(notification()
414///     .title("Notification Title")
415///     .text("Notification message"));
416/// # });
417/// ```
418pub fn notification() -> MaterialNotification {
419    MaterialNotification::new()
420}