use egui::{vec2, Align2, Area, Color32, Frame, Id, Margin, Order, Sense, Stroke};
use egui_components_theme::{mix, Theme};
use crate::common::Variant;
use crate::icon::{paint_icon, IconKind};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToastAnchor {
TopRight,
TopLeft,
BottomRight,
BottomLeft,
}
struct Toast {
id: Id,
variant: Variant,
title: Option<String>,
message: String,
duration: f64,
created: Option<f64>,
}
pub struct Toasts {
anchor: ToastAnchor,
width: f32,
gap: f32,
margin: f32,
next_id: u64,
toasts: Vec<Toast>,
}
impl Default for Toasts {
fn default() -> Self {
Self {
anchor: ToastAnchor::TopRight,
width: 320.0,
gap: 8.0,
margin: 16.0,
next_id: 0,
toasts: Vec::new(),
}
}
}
impl Toasts {
pub fn new() -> Self {
Self::default()
}
pub fn anchor(mut self, anchor: ToastAnchor) -> Self {
self.anchor = anchor;
self
}
pub fn width(mut self, w: f32) -> Self {
self.width = w;
self
}
pub fn add(&mut self, variant: Variant, title: Option<String>, message: impl Into<String>) {
let id = Id::new(("toast", self.next_id));
self.next_id = self.next_id.wrapping_add(1);
self.toasts.push(Toast {
id,
variant,
title,
message: message.into(),
duration: 4.0,
created: None,
});
}
pub fn info(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.add(Variant::Info, Some(title.into()), message);
}
pub fn success(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.add(Variant::Success, Some(title.into()), message);
}
pub fn warning(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.add(Variant::Warning, Some(title.into()), message);
}
pub fn error(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.add(Variant::Danger, Some(title.into()), message);
}
pub fn show(&mut self, ctx: &egui::Context) {
if self.toasts.is_empty() {
return;
}
let now = ctx.input(|i| i.time);
let theme = Theme::get(ctx);
let (pivot, base) = match self.anchor {
ToastAnchor::TopRight => (
Align2::RIGHT_TOP,
ctx.content_rect().right_top() + vec2(-self.margin, self.margin),
),
ToastAnchor::TopLeft => (
Align2::LEFT_TOP,
ctx.content_rect().left_top() + vec2(self.margin, self.margin),
),
ToastAnchor::BottomRight => (
Align2::RIGHT_BOTTOM,
ctx.content_rect().right_bottom() + vec2(-self.margin, -self.margin),
),
ToastAnchor::BottomLeft => (
Align2::LEFT_BOTTOM,
ctx.content_rect().left_bottom() + vec2(self.margin, -self.margin),
),
};
let stack_down = matches!(self.anchor, ToastAnchor::TopRight | ToastAnchor::TopLeft);
let mut remove: Vec<Id> = Vec::new();
let mut offset_y = 0.0;
let mut need_repaint = false;
for toast in self.toasts.iter_mut() {
if toast.created.is_none() {
toast.created = Some(now);
}
let age = now - toast.created.unwrap_or(now);
let anchor_pos = base + vec2(0.0, if stack_down { offset_y } else { -offset_y });
let resp = Area::new(toast.id)
.order(Order::Foreground)
.fixed_pos(anchor_pos)
.pivot(pivot)
.show(ctx, |ui| {
ui.set_width(self.width);
paint_toast(ui, &theme, toast)
});
let card_h = resp.response.rect.height();
offset_y += card_h + self.gap;
let hovered = resp.response.hovered();
if resp.inner {
remove.push(toast.id);
} else if !hovered && age >= toast.duration {
remove.push(toast.id);
} else if !hovered {
need_repaint = true;
}
}
self.toasts.retain(|t| !remove.contains(&t.id));
if need_repaint {
ctx.request_repaint();
}
}
}
fn paint_toast(ui: &mut egui::Ui, theme: &Theme, toast: &Toast) -> bool {
let c = theme.colors;
let (accent, icon) = toast_accent(&c, toast.variant);
let mut close_clicked = false;
Frame::new()
.fill(c.popover_background)
.stroke(Stroke::new(theme.metrics.border_width, c.border))
.corner_radius(theme.corner())
.inner_margin(Margin::same(12))
.shadow(egui::epaint::Shadow {
offset: [0, 4],
blur: 18,
spread: 0,
color: c.overlay,
})
.show(ui, |ui| {
ui.horizontal_top(|ui| {
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
paint_icon(ui.painter(), icon, ir, accent, 1.8);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.set_width(ui.available_width() - 22.0);
if let Some(title) = &toast.title {
ui.add(
crate::label::Label::new(title.clone())
.strong()
.size(crate::common::Size::Small),
);
}
ui.add(
crate::label::Label::new(toast.message.clone())
.muted()
.size(crate::common::Size::Small),
);
});
let (x_rect, x_resp) =
ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
let x_color = if x_resp.hovered() {
c.foreground
} else {
c.muted_foreground
};
paint_icon(ui.painter(), IconKind::Close, x_rect, x_color, 1.4);
if x_resp.clicked() {
close_clicked = true;
}
if x_resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
});
});
close_clicked
}
fn toast_accent(c: &egui_components_theme::ThemeColor, v: Variant) -> (Color32, IconKind) {
match v {
Variant::Success => (c.success_background, IconKind::Check),
Variant::Warning => (c.warning_background, IconKind::Warning),
Variant::Danger => (c.danger_background, IconKind::Error),
Variant::Info => (c.info_background, IconKind::Info),
_ => (mix(c.foreground, c.muted_foreground, 0.3), IconKind::Info),
}
}