1use 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
53const TOAST_WIDTH: f32 = 356.0; const TOAST_PADDING: f32 = 16.0; const TOAST_CORNER_RADIUS: f32 = 8.0; const TOAST_HEIGHT: f32 = 70.0; const TOAST_SPACING: f32 = 8.0; const TOAST_MARGIN: f32 = 16.0; const DEFAULT_DURATION_SECS: f32 = 4.0; const PROGRESS_HEIGHT: f32 = 2.0; const MAX_TOASTS: usize = 5;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum ToastVariant {
67 #[default]
69 Default,
70 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ToastPosition {
86 TopLeft,
88 TopCenter,
90 TopRight,
92 BottomLeft,
94 BottomCenter,
96 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub struct ToastId(u64);
129
130#[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 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 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#[derive(Clone)]
191pub struct ToastManager {
192 toasts: VecDeque<Toast>,
193 position: ToastPosition,
194 max_toasts: usize,
195 width: f32,
196}
197
198impl ToastManager {
199 #[must_use]
201 pub const fn new() -> Self {
202 Self {
203 toasts: VecDeque::new(),
204 position: ToastPosition::BottomRight, max_toasts: MAX_TOASTS,
206 width: TOAST_WIDTH,
207 }
208 }
209
210 #[must_use]
212 pub const fn position(mut self, position: ToastPosition) -> Self {
213 self.position = position;
214 self
215 }
216
217 #[must_use]
219 pub const fn max_toasts(mut self, max: usize) -> Self {
220 self.max_toasts = max;
221 self
222 }
223
224 #[must_use]
226 pub const fn width(mut self, width: f32) -> Self {
227 self.width = width;
228 self
229 }
230
231 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 pub fn toast(&mut self, message: impl Into<String>) -> ToastId {
250 self.add(message, ToastVariant::Default, 0.0)
251 }
252
253 pub fn error(&mut self, message: impl Into<String>) -> ToastId {
255 self.add(message, ToastVariant::Destructive, 0.0)
256 }
257
258 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 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 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 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 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 pub fn dismiss(&mut self, id: ToastId) {
298 self.toasts.retain(|t| t.id != id.0);
299 }
300
301 pub const fn custom(&mut self) -> ToastBuilder<'_> {
303 ToastBuilder {
304 manager: self,
305 toast: None,
306 }
307 }
308
309 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 for toast in &mut self.toasts {
316 if toast.created_at == 0.0 {
317 toast.created_at = current_time;
318 }
319 }
320
321 self.toasts.retain(|toast| !toast.is_expired(current_time));
323
324 if self.toasts.is_empty() {
325 return;
326 }
327
328 let mut to_remove = Vec::new();
330 let position = self.position;
331
332 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 let toasts_to_render: Vec<_> = self.toasts.iter().cloned().collect();
343
344 for (index, toast) in toasts_to_render.iter().enumerate() {
345 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 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 for id in to_remove {
390 self.toasts.retain(|t| t.id != id);
391 }
392
393 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 Card::new()
422 .variant(CardVariant::Outlined) .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 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 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 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 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 ui.painter().rect_filled(rect, 1.0, theme.muted());
494
495 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
517pub struct ToastBuilder<'a> {
519 manager: &'a mut ToastManager,
520 toast: Option<Toast>,
521}
522
523impl ToastBuilder<'_> {
524 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}