Skip to main content

egui_macview/feedback/
toast.rs

1//! Authentic macOS Animated Toast Notifications.
2//!
3//! Normative Reference: SPEC-008B ยง4.
4//! Floating pill banner with `CASpring` entry/exit physics and 2.5s auto-dismiss timer.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use crate::motion::CASpring;
9use crate::theme::MacTheme;
10use egui::{Area, CornerRadius, FontId, Id, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2};
11
12static TOAST_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
13
14pub use crate::tokens::DEFAULT_TOAST_DURATION;
15
16/// Semantic styling variant of the toast pill.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ToastLevel {
19    /// Informational toast with neutral appearance.
20    #[default]
21    Info,
22    /// Success toast with Apple system green accent.
23    Success,
24    /// Warning toast with system accent warning color.
25    Warning,
26    /// Error toast with crimson alert accent.
27    Error,
28}
29
30/// Individual animated toast notification banner.
31#[derive(Debug, Clone)]
32pub struct MacToast {
33    /// Unique identifier for this toast instance.
34    pub id: String,
35    /// Message string displayed in the pill.
36    pub message: String,
37    /// Severity level determining border and icon color.
38    pub level: ToastLevel,
39    /// Total duration in seconds before auto-dismissal.
40    pub duration: f32,
41    /// Elapsed seconds since presentation.
42    pub elapsed: f32,
43    /// CoreAnimation spring physics driver for entry/exit animation.
44    pub spring: CASpring,
45    /// Whether the toast has completed display and is dismissed.
46    pub is_dismissed: bool,
47}
48
49impl MacToast {
50    /// Creates a new toast notification with the standard 2.5s auto-dismiss duration.
51    pub fn new(message: impl Into<String>) -> Self {
52        let default_theme = MacTheme::default();
53        Self {
54            id: TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed).to_string(),
55            message: message.into(),
56            level: ToastLevel::Info,
57            duration: DEFAULT_TOAST_DURATION,
58            elapsed: 0.0,
59            spring: default_theme.motion().caspring_default.to_spring(),
60            is_dismissed: false,
61        }
62    }
63
64    /// Sets semantic severity level.
65    pub fn level(mut self, level: ToastLevel) -> Self {
66        self.level = level;
67        self
68    }
69
70    /// Overrides default 2.5s display duration.
71    pub fn duration(mut self, d: f32) -> Self {
72        self.duration = d;
73        self
74    }
75
76    /// Updates elapsed time by delta seconds `dt`. Automatically marks as dismissed when expired.
77    pub fn update(&mut self, dt: f32) {
78        self.elapsed += dt;
79        if self.elapsed >= self.duration {
80            self.is_dismissed = true;
81        }
82    }
83
84    /// Returns whether the toast is active and visible.
85    #[inline]
86    pub fn is_active(&self) -> bool {
87        !self.is_dismissed && self.elapsed < self.duration
88    }
89
90    /// Returns the CoreAnimation spring interpolation fraction `[0.0, 1.0]`.
91    pub fn entry_fraction(&self) -> f32 {
92        self.spring.position_at(self.elapsed)
93    }
94
95    /// Explicitly marks the toast as dismissed.
96    pub fn dismiss(&mut self) {
97        self.is_dismissed = true;
98    }
99}
100
101/// Manager container orchestrating active toast notifications.
102#[derive(Default)]
103pub struct MacToastManager {
104    toasts: Vec<MacToast>,
105}
106
107impl MacToastManager {
108    /// Creates an empty toast manager.
109    pub fn new() -> Self {
110        Self { toasts: Vec::new() }
111    }
112
113    /// Adds a new toast to the manager queue.
114    pub fn add(&mut self, toast: MacToast) {
115        self.toasts.push(toast);
116    }
117
118    /// Shows an info message toast.
119    pub fn info(&mut self, message: impl Into<String>) {
120        self.add(MacToast::new(message).level(ToastLevel::Info));
121    }
122
123    /// Shows a success message toast.
124    pub fn success(&mut self, message: impl Into<String>) {
125        self.add(MacToast::new(message).level(ToastLevel::Success));
126    }
127
128    /// Shows a warning message toast.
129    pub fn warning(&mut self, message: impl Into<String>) {
130        self.add(MacToast::new(message).level(ToastLevel::Warning));
131    }
132
133    /// Shows an error message toast.
134    pub fn error(&mut self, message: impl Into<String>) {
135        self.add(MacToast::new(message).level(ToastLevel::Error));
136    }
137
138    /// Updates all active toasts with time step `dt` and removes expired ones.
139    pub fn update(&mut self, dt: f32) {
140        for toast in &mut self.toasts {
141            toast.update(dt);
142        }
143        self.toasts.retain(|t| t.is_active());
144    }
145
146    /// Number of active toasts.
147    pub fn active_count(&self) -> usize {
148        self.toasts.iter().filter(|t| t.is_active()).count()
149    }
150
151    /// Returns a slice of active toasts.
152    pub fn active_toasts(&self) -> &[MacToast] {
153        &self.toasts
154    }
155
156    /// Clears all active toasts.
157    pub fn clear(&mut self) {
158        self.toasts.clear();
159    }
160
161    /// Renders active toasts as floating pill banners in the given UI context.
162    pub fn show(&mut self, ctx: &egui::Context, theme: &MacTheme) {
163        if self.toasts.is_empty() {
164            return;
165        }
166        let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.5);
167        self.update(dt);
168
169        if self.toasts.is_empty() {
170            return;
171        }
172        let mut anim_in_flight = false;
173        let mut next_wake = f32::MAX;
174        for t in &self.toasts {
175            if t.elapsed < t.spring.settling_duration() {
176                anim_in_flight = true;
177            } else {
178                next_wake = next_wake.min((t.duration - t.elapsed).max(0.0));
179            }
180        }
181        if anim_in_flight {
182            ctx.request_repaint_after(std::time::Duration::from_millis(16));
183        } else if next_wake < f32::MAX {
184            ctx.request_repaint_after_secs(next_wake);
185        }
186
187        let tm = theme.toast_metrics();
188        let start_y = tm.top_margin.points();
189        let toast_h = tm.height.points();
190        let spacing = tm.spacing.points();
191        let padding_h = tm.padding_x.points() * 0.5;
192        let min_w = tm.min_width.points();
193        let border_w = theme.popover_metrics().border_width.points();
194        let font_id = FontId::proportional(tm.font_size.points());
195        let on_accent = theme.on_accent_text();
196
197        let fallback_w = theme.palette_metrics().width.points();
198        let fallback_h = theme.palette_metrics().max_height.points();
199        let fallback_screen = Vec2::new(fallback_w * 2.0, fallback_h * 2.0);
200        let screen_rect = ctx
201            .input(|i| i.raw.screen_rect)
202            .unwrap_or(Rect::from_min_size(Pos2::ZERO, fallback_screen));
203        let area_id = Id::NULL.with("mac_toast_layer");
204
205        Area::new(area_id)
206            .order(Order::Tooltip)
207            .fixed_pos(screen_rect.min)
208            .show(ctx, |ui| {
209                for (idx, toast) in self.toasts.iter_mut().enumerate() {
210                    let fraction = toast.entry_fraction().clamp(0.0, 1.0 + 0.5);
211                    let target_y = start_y + (idx as f32) * (toast_h + spacing);
212                    let anim_y = -toast_h + (target_y - (-toast_h)) * fraction;
213
214                    let galley = ui.painter().layout_no_wrap(
215                        toast.message.clone(),
216                        font_id.clone(),
217                        theme.label(),
218                    );
219                    let toast_w = (galley.size().x + padding_h * 2.0).max(min_w);
220                    let center_x = screen_rect.center().x;
221
222                    let toast_rect = Rect::from_center_size(
223                        Pos2::new(center_x, anim_y + toast_h * 0.5),
224                        Vec2::new(toast_w, toast_h),
225                    );
226
227                    let painter = ui.painter();
228                    let (bg, border) = match toast.level {
229                        ToastLevel::Info => (theme.window_background(), theme.separator()),
230                        ToastLevel::Success => (theme.system_green(), theme.system_green()),
231                        ToastLevel::Warning => (theme.control_accent(), theme.control_accent()),
232                        ToastLevel::Error => {
233                            let (c_fill, c_border) = theme.traffic_light_close();
234                            (c_fill, c_border)
235                        }
236                    };
237
238                    painter.rect(
239                        toast_rect,
240                        CornerRadius::same((toast_h * 0.5) as u8),
241                        bg,
242                        Stroke::new(border_w, border),
243                        StrokeKind::Inside,
244                    );
245
246                    let fg = match toast.level {
247                        ToastLevel::Info => theme.label(),
248                        _ => on_accent,
249                    };
250
251                    painter.galley(
252                        Pos2::new(
253                            toast_rect.center().x - galley.size().x * 0.5,
254                            toast_rect.center().y - galley.size().y * 0.5,
255                        ),
256                        galley,
257                        fg,
258                    );
259
260                    let resp = ui.interact(toast_rect, area_id.with(&toast.id), Sense::click());
261                    if resp.clicked() {
262                        toast.dismiss();
263                    }
264                }
265            });
266    }
267}