Skip to main content

armas_basic/components/
toast.rs

1//! Toast/Notification Components
2//!
3//! Toast notifications styled like shadcn/ui Sonner (Toast).
4//! Supports multiple positions, variants, and auto-dismiss with progress indicators.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # use egui::Context;
10//! # fn example(ctx: &Context) {
11//! use armas_basic::components::{ToastManager, ToastVariant};
12//!
13//! let mut toasts = ToastManager::new();
14//!
15//! // Simple toast
16//! toasts.toast("Changes saved");
17//!
18//! // Error toast
19//! toasts.error("Something went wrong");
20//!
21//! // Custom toast
22//! toasts.custom()
23//!     .title("Scheduled")
24//!     .message("Your message has been scheduled")
25//!     .duration(std::time::Duration::from_secs(5))
26//!     .show();
27//!
28//! // Progress toast (externally driven, won't auto-dismiss)
29//! let id = toasts.custom()
30//!     .title("Exporting")
31//!     .message("Rendering audio...")
32//!     .progress(0.0)
33//!     .show();
34//! // Later: update progress
35//! toasts.set_progress(id, 0.5);
36//! toasts.set_message(id, "Encoding...");
37//! // When done: switch to auto-dismiss countdown
38//! toasts.set_message(id, "Export complete!");
39//! toasts.start_dismiss(id, ctx.input(|i| i.time));
40//!
41//! // Render all toasts
42//! toasts.show(ctx);
43//! # }
44//! ```
45
46use crate::animation::SpringAnimation;
47use crate::ext::ArmasContextExt;
48use crate::icon;
49use crate::{Card, CardVariant, Theme};
50use egui::{vec2, Align2, Color32, Id, Sense, Vec2};
51use std::collections::VecDeque;
52
53// shadcn Sonner (Toast) constants
54const TOAST_WIDTH: f32 = 356.0; // w-[356px]
55const TOAST_PADDING: f32 = 16.0; // p-4
56const TOAST_CORNER_RADIUS: f32 = 8.0; // rounded-lg
57const TOAST_HEIGHT: f32 = 70.0; // Approximate height
58const TOAST_SPACING: f32 = 8.0; // gap-2
59const TOAST_MARGIN: f32 = 16.0; // 1rem margin
60const DEFAULT_DURATION_SECS: f32 = 4.0; // 4s default
61const PROGRESS_HEIGHT: f32 = 2.0; // h-0.5
62const MAX_TOASTS: usize = 5;
63
64/// Toast notification variant
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum ToastVariant {
67    /// Default notification
68    #[default]
69    Default,
70    /// Destructive/error notification (red)
71    Destructive,
72}
73
74impl ToastVariant {
75    const fn color(self, theme: &Theme) -> Color32 {
76        match self {
77            Self::Default => theme.foreground(),
78            Self::Destructive => theme.destructive(),
79        }
80    }
81}
82
83/// Position for toast notifications
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ToastPosition {
86    /// Top left corner
87    TopLeft,
88    /// Top center
89    TopCenter,
90    /// Top right corner
91    TopRight,
92    /// Bottom left corner
93    BottomLeft,
94    /// Bottom center
95    BottomCenter,
96    /// Bottom right corner
97    BottomRight,
98}
99
100impl ToastPosition {
101    const fn anchor(self) -> Align2 {
102        match self {
103            Self::TopLeft => Align2::LEFT_TOP,
104            Self::TopCenter => Align2::CENTER_TOP,
105            Self::TopRight => Align2::RIGHT_TOP,
106            Self::BottomLeft => Align2::LEFT_BOTTOM,
107            Self::BottomCenter => Align2::CENTER_BOTTOM,
108            Self::BottomRight => Align2::RIGHT_BOTTOM,
109        }
110    }
111
112    fn offset(self, index: usize, toast_height: f32) -> Vec2 {
113        let y_offset = (toast_height + TOAST_SPACING) * index as f32;
114
115        match self {
116            Self::TopLeft => vec2(TOAST_MARGIN, TOAST_MARGIN + y_offset),
117            Self::TopCenter => vec2(0.0, TOAST_MARGIN + y_offset),
118            Self::TopRight => vec2(-TOAST_MARGIN, TOAST_MARGIN + y_offset),
119            Self::BottomLeft => vec2(TOAST_MARGIN, -TOAST_MARGIN - y_offset),
120            Self::BottomCenter => vec2(0.0, -TOAST_MARGIN - y_offset),
121            Self::BottomRight => vec2(-TOAST_MARGIN, -TOAST_MARGIN - y_offset),
122        }
123    }
124}
125
126/// Opaque handle returned when creating a toast, used to update or dismiss it.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub struct ToastId(u64);
129
130/// Individual toast notification
131#[derive(Clone)]
132struct Toast {
133    id: u64,
134    title: Option<String>,
135    message: String,
136    variant: ToastVariant,
137    custom_color: Option<Color32>,
138    duration_secs: f32,
139    created_at: f64,
140    slide_animation: SpringAnimation,
141    dismissible: bool,
142    /// When set, the progress bar shows this value (0.0..=1.0)
143    /// instead of the time-based countdown.
144    external_progress: Option<f32>,
145}
146
147use std::sync::atomic::{AtomicU64, Ordering};
148
149impl Toast {
150    fn new(message: impl Into<String>, variant: ToastVariant, current_time: f64) -> Self {
151        static NEXT_ID: AtomicU64 = AtomicU64::new(0);
152        let id = NEXT_ID.fetch_add(1, Ordering::Relaxed) + 1;
153
154        Self {
155            id,
156            title: None,
157            message: message.into(),
158            variant,
159            custom_color: None,
160            duration_secs: DEFAULT_DURATION_SECS,
161            created_at: current_time,
162            slide_animation: SpringAnimation::new(0.0, 1.0).params(250.0, 25.0),
163            dismissible: true,
164            external_progress: None,
165        }
166    }
167
168    fn is_expired(&self, current_time: f64) -> bool {
169        // Externally-driven toasts never auto-expire.
170        if self.external_progress.is_some() {
171            return false;
172        }
173        (current_time - self.created_at) as f32 >= self.duration_secs
174    }
175
176    fn progress(&self, current_time: f64) -> f32 {
177        if let Some(p) = self.external_progress {
178            return p.clamp(0.0, 1.0);
179        }
180        ((current_time - self.created_at) as f32 / self.duration_secs).min(1.0)
181    }
182
183    fn color(&self, theme: &Theme) -> Color32 {
184        self.custom_color
185            .unwrap_or_else(|| self.variant.color(theme))
186    }
187}
188
189/// Toast notification manager
190#[derive(Clone)]
191pub struct ToastManager {
192    toasts: VecDeque<Toast>,
193    position: ToastPosition,
194    max_toasts: usize,
195    width: f32,
196}
197
198impl ToastManager {
199    /// Create a new toast manager
200    #[must_use]
201    pub const fn new() -> Self {
202        Self {
203            toasts: VecDeque::new(),
204            position: ToastPosition::BottomRight, // shadcn default
205            max_toasts: MAX_TOASTS,
206            width: TOAST_WIDTH,
207        }
208    }
209
210    /// Set the position where toasts appear
211    #[must_use]
212    pub const fn position(mut self, position: ToastPosition) -> Self {
213        self.position = position;
214        self
215    }
216
217    /// Set the maximum number of toasts to show at once
218    #[must_use]
219    pub const fn max_toasts(mut self, max: usize) -> Self {
220        self.max_toasts = max;
221        self
222    }
223
224    /// Set the width of toast notifications
225    #[must_use]
226    pub const fn width(mut self, width: f32) -> Self {
227        self.width = width;
228        self
229    }
230
231    /// Add a new toast notification, returning its ID for later updates.
232    pub fn add(
233        &mut self,
234        message: impl Into<String>,
235        variant: ToastVariant,
236        current_time: f64,
237    ) -> ToastId {
238        let toast = Toast::new(message, variant, current_time);
239        let id = ToastId(toast.id);
240        self.toasts.push_back(toast);
241
242        while self.toasts.len() > self.max_toasts {
243            self.toasts.pop_front();
244        }
245        id
246    }
247
248    /// Add a default toast
249    pub fn toast(&mut self, message: impl Into<String>) -> ToastId {
250        self.add(message, ToastVariant::Default, 0.0)
251    }
252
253    /// Add a destructive/error toast
254    pub fn error(&mut self, message: impl Into<String>) -> ToastId {
255        self.add(message, ToastVariant::Destructive, 0.0)
256    }
257
258    /// Update the progress bar of a toast (0.0..=1.0).
259    /// While external progress is set the toast will not auto-dismiss.
260    pub fn set_progress(&mut self, id: ToastId, progress: f32) {
261        if let Some(toast) = self.toasts.iter_mut().find(|t| t.id == id.0) {
262            toast.external_progress = Some(progress.clamp(0.0, 1.0));
263        }
264    }
265
266    /// Update the message text of an existing toast.
267    pub fn set_message(&mut self, id: ToastId, message: impl Into<String>) {
268        if let Some(toast) = self.toasts.iter_mut().find(|t| t.id == id.0) {
269            toast.message = message.into();
270        }
271    }
272
273    /// Update the title of an existing toast.
274    pub fn set_title(&mut self, id: ToastId, title: impl Into<String>) {
275        if let Some(toast) = self.toasts.iter_mut().find(|t| t.id == id.0) {
276            toast.title = Some(title.into());
277        }
278    }
279
280    /// Update the variant of an existing toast.
281    pub fn set_variant(&mut self, id: ToastId, variant: ToastVariant) {
282        if let Some(toast) = self.toasts.iter_mut().find(|t| t.id == id.0) {
283            toast.variant = variant;
284        }
285    }
286
287    /// Remove external progress and start the auto-dismiss countdown from now.
288    /// Useful after a task completes to show a brief "done" message before fading.
289    pub fn start_dismiss(&mut self, id: ToastId, current_time: f64) {
290        if let Some(toast) = self.toasts.iter_mut().find(|t| t.id == id.0) {
291            toast.external_progress = None;
292            toast.created_at = current_time;
293        }
294    }
295
296    /// Immediately remove a toast by ID.
297    pub fn dismiss(&mut self, id: ToastId) {
298        self.toasts.retain(|t| t.id != id.0);
299    }
300
301    /// Add a custom toast with builder pattern
302    pub const fn custom(&mut self) -> ToastBuilder<'_> {
303        ToastBuilder {
304            manager: self,
305            toast: None,
306        }
307    }
308
309    /// Show all toasts
310    pub fn show(&mut self, ctx: &egui::Context) {
311        let theme = ctx.armas_theme();
312        let current_time = ctx.input(|i| i.time);
313
314        // Fix newly created toasts (created_at == 0.0)
315        for toast in &mut self.toasts {
316            if toast.created_at == 0.0 {
317                toast.created_at = current_time;
318            }
319        }
320
321        // Remove expired toasts
322        self.toasts.retain(|toast| !toast.is_expired(current_time));
323
324        if self.toasts.is_empty() {
325            return;
326        }
327
328        // Animate and draw toasts
329        let mut to_remove = Vec::new();
330        let position = self.position;
331
332        // Update animations first
333        let dt = ctx.input(|i| i.unstable_dt);
334        for toast in &mut self.toasts {
335            toast.slide_animation.update(dt);
336            if !toast.slide_animation.is_settled(0.001, 0.001) {
337                ctx.request_repaint();
338            }
339        }
340
341        // Clone toast data for rendering to avoid borrow conflicts
342        let toasts_to_render: Vec<_> = self.toasts.iter().cloned().collect();
343
344        for (index, toast) in toasts_to_render.iter().enumerate() {
345            // Fade out animation near end (skip for externally-driven toasts)
346            let opacity = if toast.external_progress.is_some() {
347                1.0
348            } else {
349                let progress = toast.progress(current_time);
350                let fade_start = 0.9;
351                if progress > fade_start {
352                    1.0 - ((progress - fade_start) / (1.0 - fade_start))
353                } else {
354                    1.0
355                }
356            };
357
358            // Slide in animation using spring
359            let slide_progress = toast.slide_animation.value;
360
361            let offset = position.offset(index, TOAST_HEIGHT);
362            let slide_offset = match position {
363                ToastPosition::TopRight | ToastPosition::BottomRight => {
364                    vec2(50.0 * (1.0 - slide_progress), 0.0)
365                }
366                ToastPosition::TopLeft | ToastPosition::BottomLeft => {
367                    vec2(-50.0 * (1.0 - slide_progress), 0.0)
368                }
369                _ => vec2(0.0, 0.0),
370            };
371
372            let dismissed = Self::show_toast_static(
373                ctx,
374                &theme,
375                toast,
376                position,
377                offset + slide_offset,
378                opacity,
379                current_time,
380                self.width,
381            );
382
383            if dismissed {
384                to_remove.push(toast.id);
385            }
386        }
387
388        // Remove dismissed toasts
389        for id in to_remove {
390            self.toasts.retain(|t| t.id != id);
391        }
392
393        // Request repaint if any toasts are active
394        if !self.toasts.is_empty() {
395            ctx.request_repaint();
396        }
397    }
398
399    fn show_toast_static(
400        ctx: &egui::Context,
401        theme: &Theme,
402        toast: &Toast,
403        position: ToastPosition,
404        offset: Vec2,
405        opacity: f32,
406        current_time: f64,
407        width: f32,
408    ) -> bool {
409        let mut dismissed = false;
410
411        egui::Area::new(Id::new("toast").with(toast.id))
412            .order(egui::Order::Foreground)
413            .interactable(false)
414            .anchor(position.anchor(), offset)
415            .show(ctx, |ui| {
416                ui.set_opacity(opacity);
417
418                let accent_color = toast.color(theme);
419
420                // Use Card for consistent styling (shadcn toast style)
421                Card::new()
422                    .variant(CardVariant::Outlined) // shadcn uses border
423                    .width(width)
424                    .stroke(theme.border())
425                    .corner_radius(TOAST_CORNER_RADIUS)
426                    .inner_margin(TOAST_PADDING)
427                    .show(ui, |ui| {
428                        ui.horizontal(|ui| {
429                            ui.spacing_mut().item_spacing.x = TOAST_SPACING;
430
431                            // Icon
432                            let icon_size = 16.0;
433                            let (rect, _) =
434                                ui.allocate_exact_size(vec2(icon_size, icon_size), Sense::hover());
435                            match toast.variant {
436                                ToastVariant::Default => {
437                                    icon::draw_info(ui.painter(), rect, accent_color);
438                                }
439                                ToastVariant::Destructive => {
440                                    icon::draw_error(ui.painter(), rect, accent_color);
441                                }
442                            }
443
444                            // Content
445                            ui.vertical(|ui| {
446                                ui.spacing_mut().item_spacing.y = 0.0;
447                                ui.set_width(width - 100.0);
448
449                                if let Some(title) = &toast.title {
450                                    ui.strong(title);
451                                }
452                                ui.label(&toast.message);
453                            });
454
455                            // Close button
456                            if toast.dismissible {
457                                let btn_size = 24.0;
458                                let (close_rect, close_response) = ui
459                                    .allocate_exact_size(vec2(btn_size, btn_size), Sense::click());
460                                if ui.is_rect_visible(close_rect) {
461                                    if close_response.hovered() {
462                                        ui.painter().rect_filled(close_rect, 4.0, theme.accent());
463                                    }
464                                    let icon_color = if close_response.hovered() {
465                                        theme.foreground()
466                                    } else {
467                                        theme.muted_foreground()
468                                    };
469                                    let icon_rect = egui::Rect::from_center_size(
470                                        close_rect.center(),
471                                        vec2(12.0, 12.0),
472                                    );
473                                    icon::draw_close(ui.painter(), icon_rect, icon_color);
474                                }
475
476                                if close_response.clicked() {
477                                    dismissed = true;
478                                }
479                            }
480                        });
481
482                        // Progress bar (shadcn style)
483                        let progress = toast.progress(current_time).min(1.0);
484                        let show_progress = toast.external_progress.is_some() || progress < 1.0;
485                        if show_progress {
486                            ui.add_space(TOAST_SPACING);
487                            let (rect, _) = ui.allocate_exact_size(
488                                vec2(ui.available_width(), PROGRESS_HEIGHT),
489                                Sense::hover(),
490                            );
491
492                            // Background
493                            ui.painter().rect_filled(rect, 1.0, theme.muted());
494
495                            // Progress fill
496                            let fill_width = rect.width() * progress;
497                            let fill_rect = egui::Rect::from_min_size(
498                                rect.min,
499                                vec2(fill_width, PROGRESS_HEIGHT),
500                            );
501
502                            ui.painter().rect_filled(fill_rect, 1.0, accent_color);
503                        }
504                    });
505            });
506
507        dismissed
508    }
509}
510
511impl Default for ToastManager {
512    fn default() -> Self {
513        Self::new()
514    }
515}
516
517/// Builder for custom toast notifications
518pub struct ToastBuilder<'a> {
519    manager: &'a mut ToastManager,
520    toast: Option<Toast>,
521}
522
523impl ToastBuilder<'_> {
524    /// Set the toast message
525    #[must_use]
526    pub fn message(mut self, message: impl Into<String>) -> Self {
527        if let Some(toast) = &mut self.toast {
528            toast.message = message.into();
529        } else {
530            self.toast = Some(Toast::new(message, ToastVariant::Default, 0.0));
531        }
532        self
533    }
534
535    /// Set the toast title
536    #[must_use]
537    pub fn title(mut self, title: impl Into<String>) -> Self {
538        if let Some(toast) = &mut self.toast {
539            toast.title = Some(title.into());
540        }
541        self
542    }
543
544    /// Set the toast variant
545    #[must_use]
546    pub fn variant(mut self, variant: ToastVariant) -> Self {
547        if let Some(toast) = &mut self.toast {
548            toast.variant = variant;
549        } else {
550            self.toast = Some(Toast::new("", variant, 0.0));
551        }
552        self
553    }
554
555    /// Make this a destructive toast
556    #[must_use]
557    pub fn destructive(mut self) -> Self {
558        if let Some(toast) = &mut self.toast {
559            toast.variant = ToastVariant::Destructive;
560        } else {
561            self.toast = Some(Toast::new("", ToastVariant::Destructive, 0.0));
562        }
563        self
564    }
565
566    /// Set custom color (overrides variant)
567    #[must_use]
568    pub const fn color(mut self, color: Color32) -> Self {
569        if let Some(toast) = &mut self.toast {
570            toast.custom_color = Some(color);
571        }
572        self
573    }
574
575    /// Set the display duration
576    #[must_use]
577    pub const fn duration(mut self, duration: std::time::Duration) -> Self {
578        if let Some(toast) = &mut self.toast {
579            toast.duration_secs = duration.as_secs_f32();
580        }
581        self
582    }
583
584    /// Set whether the toast can be manually dismissed
585    #[must_use]
586    pub const fn dismissible(mut self, dismissible: bool) -> Self {
587        if let Some(toast) = &mut self.toast {
588            toast.dismissible = dismissible;
589        }
590        self
591    }
592
593    /// Set initial external progress (0.0..=1.0).
594    /// The toast will not auto-dismiss while external progress is set.
595    #[must_use]
596    pub const fn progress(mut self, progress: f32) -> Self {
597        if let Some(toast) = &mut self.toast {
598            toast.external_progress = Some(progress);
599        }
600        self
601    }
602
603    /// Add the toast to the manager, returning its ID for later updates.
604    #[must_use]
605    pub fn show(self) -> ToastId {
606        if let Some(toast) = self.toast {
607            let id = ToastId(toast.id);
608            self.manager.toasts.push_back(toast);
609            while self.manager.toasts.len() > self.manager.max_toasts {
610                self.manager.toasts.pop_front();
611            }
612            id
613        } else {
614            ToastId(0)
615        }
616    }
617}