use std::sync::atomic::{AtomicU64, Ordering};
use crate::motion::CASpring;
use crate::theme::MacTheme;
use egui::{
Area, Color32, CornerRadius, FontId, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2,
};
static TOAST_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
pub const DEFAULT_TOAST_DURATION: f32 = 2.5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastLevel {
#[default]
Info,
Success,
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct MacToast {
pub id: String,
pub message: String,
pub level: ToastLevel,
pub duration: f32,
pub elapsed: f32,
pub spring: CASpring,
pub is_dismissed: bool,
}
impl MacToast {
pub fn new(message: impl Into<String>) -> Self {
Self {
id: TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed).to_string(),
message: message.into(),
level: ToastLevel::Info,
duration: DEFAULT_TOAST_DURATION,
elapsed: 0.0,
spring: CASpring::new(0.35, 0.85),
is_dismissed: false,
}
}
pub fn level(mut self, level: ToastLevel) -> Self {
self.level = level;
self
}
pub fn duration(mut self, d: f32) -> Self {
self.duration = d;
self
}
pub fn update(&mut self, dt: f32) {
self.elapsed += dt;
if self.elapsed >= self.duration {
self.is_dismissed = true;
}
}
#[inline]
pub fn is_active(&self) -> bool {
!self.is_dismissed && self.elapsed < self.duration
}
pub fn entry_fraction(&self) -> f32 {
self.spring.position_at(self.elapsed)
}
pub fn dismiss(&mut self) {
self.is_dismissed = true;
}
}
#[derive(Default)]
pub struct MacToastManager {
toasts: Vec<MacToast>,
}
impl MacToastManager {
pub fn new() -> Self {
Self { toasts: Vec::new() }
}
pub fn add(&mut self, toast: MacToast) {
self.toasts.push(toast);
}
pub fn info(&mut self, message: impl Into<String>) {
self.add(MacToast::new(message).level(ToastLevel::Info));
}
pub fn success(&mut self, message: impl Into<String>) {
self.add(MacToast::new(message).level(ToastLevel::Success));
}
pub fn warning(&mut self, message: impl Into<String>) {
self.add(MacToast::new(message).level(ToastLevel::Warning));
}
pub fn error(&mut self, message: impl Into<String>) {
self.add(MacToast::new(message).level(ToastLevel::Error));
}
pub fn update(&mut self, dt: f32) {
for toast in &mut self.toasts {
toast.update(dt);
}
self.toasts.retain(|t| t.is_active());
}
pub fn active_count(&self) -> usize {
self.toasts.iter().filter(|t| t.is_active()).count()
}
pub fn active_toasts(&self) -> &[MacToast] {
&self.toasts
}
pub fn clear(&mut self) {
self.toasts.clear();
}
pub fn show(&mut self, ctx: &egui::Context, theme: &MacTheme) {
let dt = ctx.input(|i| i.stable_dt).min(0.1);
self.update(dt);
if self.toasts.is_empty() {
return;
}
let screen_rect = ctx
.input(|i| i.raw.screen_rect)
.unwrap_or(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)));
#[allow(deprecated)]
let area_id = egui::Id::new("mac_toast_layer");
Area::new(area_id)
.order(Order::Tooltip)
.fixed_pos(screen_rect.min)
.show(ctx, |ui| {
let start_y = 20.0f32;
let toast_h = 32.0f32;
let spacing = 8.0f32;
for (idx, toast) in self.toasts.iter_mut().enumerate() {
let fraction = toast.entry_fraction().clamp(0.0, 1.2);
let target_y = start_y + (idx as f32) * (toast_h + spacing);
let anim_y = -toast_h + (target_y - (-toast_h)) * fraction;
let font_id = FontId::proportional(12.0);
let galley = ui.painter().layout_no_wrap(
toast.message.clone(),
font_id.clone(),
theme.label(),
);
let toast_w = (galley.size().x + 32.0).max(120.0);
let center_x = screen_rect.center().x;
let toast_rect = Rect::from_center_size(
Pos2::new(center_x, anim_y + toast_h * 0.5),
Vec2::new(toast_w, toast_h),
);
let painter = ui.painter();
let (bg, border) = match toast.level {
ToastLevel::Info => (theme.window_background(), theme.separator()),
ToastLevel::Success => (theme.system_green(), theme.system_green()),
ToastLevel::Warning => (theme.control_accent(), theme.control_accent()),
ToastLevel::Error => {
(theme.traffic_light_close().0, theme.traffic_light_close().1)
}
};
painter.rect(
toast_rect,
CornerRadius::same((toast_h * 0.5) as u8),
bg,
Stroke::new(1.0, border),
StrokeKind::Inside,
);
let fg = match toast.level {
ToastLevel::Info => theme.label(),
_ => Color32::WHITE,
};
painter.galley(
Pos2::new(
toast_rect.center().x - galley.size().x * 0.5,
toast_rect.center().y - galley.size().y * 0.5,
),
galley,
fg,
);
let resp = ui.interact(toast_rect, area_id.with(&toast.id), Sense::click());
if resp.clicked() {
toast.dismiss();
}
}
});
}
}