egui_macview/feedback/
toast.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ToastLevel {
19 #[default]
21 Info,
22 Success,
24 Warning,
26 Error,
28}
29
30#[derive(Debug, Clone)]
32pub struct MacToast {
33 pub id: String,
35 pub message: String,
37 pub level: ToastLevel,
39 pub duration: f32,
41 pub elapsed: f32,
43 pub spring: CASpring,
45 pub is_dismissed: bool,
47}
48
49impl MacToast {
50 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 pub fn level(mut self, level: ToastLevel) -> Self {
66 self.level = level;
67 self
68 }
69
70 pub fn duration(mut self, d: f32) -> Self {
72 self.duration = d;
73 self
74 }
75
76 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 #[inline]
86 pub fn is_active(&self) -> bool {
87 !self.is_dismissed && self.elapsed < self.duration
88 }
89
90 pub fn entry_fraction(&self) -> f32 {
92 self.spring.position_at(self.elapsed)
93 }
94
95 pub fn dismiss(&mut self) {
97 self.is_dismissed = true;
98 }
99}
100
101#[derive(Default)]
103pub struct MacToastManager {
104 toasts: Vec<MacToast>,
105}
106
107impl MacToastManager {
108 pub fn new() -> Self {
110 Self { toasts: Vec::new() }
111 }
112
113 pub fn add(&mut self, toast: MacToast) {
115 self.toasts.push(toast);
116 }
117
118 pub fn info(&mut self, message: impl Into<String>) {
120 self.add(MacToast::new(message).level(ToastLevel::Info));
121 }
122
123 pub fn success(&mut self, message: impl Into<String>) {
125 self.add(MacToast::new(message).level(ToastLevel::Success));
126 }
127
128 pub fn warning(&mut self, message: impl Into<String>) {
130 self.add(MacToast::new(message).level(ToastLevel::Warning));
131 }
132
133 pub fn error(&mut self, message: impl Into<String>) {
135 self.add(MacToast::new(message).level(ToastLevel::Error));
136 }
137
138 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 pub fn active_count(&self) -> usize {
148 self.toasts.iter().filter(|t| t.is_active()).count()
149 }
150
151 pub fn active_toasts(&self) -> &[MacToast] {
153 &self.toasts
154 }
155
156 pub fn clear(&mut self) {
158 self.toasts.clear();
159 }
160
161 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}