use std::{collections::VecDeque, time::Duration};
use egui::{
accesskit, Align2, Area, Color32, Context, CornerRadius, Id, Order, Pos2, Rect, Response,
Sense, Stroke, StrokeKind, Ui, Vec2,
};
use crate::theme::Theme;
use crate::BadgeTone;
const FADE_OUT: f64 = 0.20;
const DEFAULT_DURATION: f64 = 4.0;
const DEFAULT_MAX_VISIBLE: usize = 5;
const DEFAULT_WIDTH: f32 = 320.0;
const STACK_GAP: f32 = 8.0;
const CLEAR_ALL_HEIGHT: f32 = 26.0;
const CLEAR_ALL_GAP: f32 = 6.0;
const CLEAR_ALL_THRESHOLD: usize = 2;
fn storage_id() -> Id {
Id::new("elegance::toasts")
}
#[derive(Debug, Clone)]
#[must_use = "Call `show(ctx)` to enqueue the toast."]
pub struct Toast {
title: String,
description: Option<String>,
tone: BadgeTone,
duration: Option<Duration>,
}
impl Toast {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
description: None,
tone: BadgeTone::Info,
duration: Some(Duration::from_secs_f64(DEFAULT_DURATION)),
}
}
pub fn tone(mut self, tone: BadgeTone) -> Self {
self.tone = tone;
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = Some(duration);
self
}
pub fn persistent(mut self) -> Self {
self.duration = None;
self
}
pub fn show(self, ctx: &Context) {
let now = ctx.input(|i| i.time);
ctx.data_mut(|d| {
let mut state = d.get_temp::<ToastState>(storage_id()).unwrap_or_default();
let id = state.next_id;
state.next_id = state.next_id.wrapping_add(1);
state.queue.push_back(ToastEntry {
id,
title: self.title,
description: self.description,
tone: self.tone,
duration: self.duration.map(|d| d.as_secs_f64()),
birth: now,
dismiss_start: None,
});
d.insert_temp(storage_id(), state);
});
ctx.request_repaint();
}
}
#[derive(Debug, Clone)]
#[must_use = "Call `.render(ctx)` to draw the toast stack."]
pub struct Toasts {
anchor: Align2,
offset: Vec2,
max_visible: usize,
width: f32,
clear_all_button: bool,
}
impl Default for Toasts {
fn default() -> Self {
Self::new()
}
}
impl Toasts {
pub fn new() -> Self {
Self {
anchor: Align2::RIGHT_BOTTOM,
offset: Vec2::new(12.0, 12.0),
max_visible: DEFAULT_MAX_VISIBLE,
width: DEFAULT_WIDTH,
clear_all_button: false,
}
}
pub fn anchor(mut self, anchor: Align2) -> Self {
self.anchor = anchor;
self
}
pub fn offset(mut self, offset: impl Into<Vec2>) -> Self {
self.offset = offset.into();
self
}
pub fn max_visible(mut self, max_visible: usize) -> Self {
self.max_visible = max_visible.max(1);
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = width.max(120.0);
self
}
pub fn clear_all_button(mut self, enabled: bool) -> Self {
self.clear_all_button = enabled;
self
}
pub fn render(self, ctx: &Context) {
let theme = Theme::current(ctx);
let now = ctx.input(|i| i.time);
let mut state = ctx
.data_mut(|d| d.get_temp::<ToastState>(storage_id()))
.unwrap_or_default();
state.queue.retain(|entry| !entry.is_expired(now));
while state.queue.len() > self.max_visible {
state.queue.pop_front();
}
if state.queue.is_empty() {
ctx.data_mut(|d| d.insert_temp(storage_id(), state));
return;
}
let screen = ctx.content_rect();
let stack_up = matches!(self.anchor.y(), egui::Align::Max);
let entry_heights: Vec<f32> = state
.queue
.iter()
.map(|e| measure_height(ctx, &theme, e, self.width))
.collect();
let x = match self.anchor.x() {
egui::Align::Min => screen.min.x + self.offset.x,
egui::Align::Center => screen.center().x - self.width * 0.5,
egui::Align::Max => screen.max.x - self.offset.x - self.width,
};
let (mut y, step_sign): (f32, f32) = if stack_up {
(screen.max.y - self.offset.y, -1.0)
} else {
(screen.min.y + self.offset.y, 1.0)
};
let order_is_new_to_old = stack_up;
let indices: Vec<usize> = if order_is_new_to_old {
(0..state.queue.len()).rev().collect()
} else {
(0..state.queue.len()).collect()
};
let mut dismiss_ids: Vec<u64> = Vec::new();
let mut earliest_next_event: Option<f64> = None;
let mut any_animating = false;
for i in indices {
let entry = &state.queue[i];
let h = entry_heights[i];
let (top, bottom) = if step_sign < 0.0 {
(y - h, y)
} else {
(y, y + h)
};
let rect = Rect::from_min_max(Pos2::new(x, top), Pos2::new(x + self.width, bottom));
let (alpha, is_animating, next_event) = entry.alpha_and_schedule(now);
any_animating |= is_animating;
if let Some(t) = next_event {
earliest_next_event = Some(match earliest_next_event {
Some(prev) => prev.min(t),
None => t,
});
}
let area_id = Id::new(("elegance::toast", entry.id));
let resp = Area::new(area_id)
.order(Order::Tooltip)
.fixed_pos(rect.min)
.show(ctx, |ui| paint_toast(ui, &theme, entry, rect, alpha));
if resp.inner {
dismiss_ids.push(entry.id);
}
let delta = (h + STACK_GAP) * step_sign;
y += delta;
}
if !dismiss_ids.is_empty() {
for entry in state.queue.iter_mut() {
if dismiss_ids.contains(&entry.id) && entry.dismiss_start.is_none() {
entry.dismiss_start = Some(now);
}
}
}
let active_count = state
.queue
.iter()
.filter(|e| e.dismiss_start.is_none())
.count();
if self.clear_all_button && active_count >= CLEAR_ALL_THRESHOLD {
let total_h: f32 = entry_heights.iter().sum::<f32>()
+ STACK_GAP * entry_heights.len().saturating_sub(1) as f32;
let pill_top = if stack_up {
(screen.max.y - self.offset.y) - total_h - CLEAR_ALL_GAP - CLEAR_ALL_HEIGHT
} else {
(screen.min.y + self.offset.y) + total_h + CLEAR_ALL_GAP
};
let pill_rect = Rect::from_min_size(
Pos2::new(x, pill_top),
Vec2::new(self.width, CLEAR_ALL_HEIGHT),
);
let area_id = Id::new("elegance::toast::clear_all");
let resp = Area::new(area_id)
.order(Order::Tooltip)
.fixed_pos(pill_rect.min)
.show(ctx, |ui| paint_clear_all(ui, &theme, pill_rect));
if resp.inner {
for entry in state.queue.iter_mut() {
if entry.dismiss_start.is_none() {
entry.dismiss_start = Some(now);
}
}
any_animating = true;
}
}
ctx.data_mut(|d| d.insert_temp(storage_id(), state));
if any_animating {
ctx.request_repaint();
} else if let Some(at) = earliest_next_event {
let remaining = (at - now).max(0.0);
ctx.request_repaint_after(Duration::from_secs_f64(remaining));
}
}
}
#[derive(Clone, Default)]
struct ToastState {
queue: VecDeque<ToastEntry>,
next_id: u64,
}
#[derive(Clone)]
struct ToastEntry {
id: u64,
title: String,
description: Option<String>,
tone: BadgeTone,
duration: Option<f64>,
birth: f64,
dismiss_start: Option<f64>,
}
impl ToastEntry {
fn is_expired(&self, now: f64) -> bool {
if let Some(ds) = self.dismiss_start {
return now >= ds + FADE_OUT;
}
if let Some(d) = self.duration {
return now >= self.birth + d + FADE_OUT;
}
false
}
fn alpha_and_schedule(&self, now: f64) -> (f32, bool, Option<f64>) {
let fade_out_start = match self.dismiss_start {
Some(ds) => Some(ds),
None => self.duration.map(|d| self.birth + d),
};
match fade_out_start {
Some(t0) if now >= t0 => {
let progress = ((now - t0) / FADE_OUT).clamp(0.0, 1.0) as f32;
(1.0 - progress, progress < 1.0, None)
}
Some(t0) => (1.0, false, Some(t0)),
None => (1.0, false, None),
}
}
}
fn tone_accent(theme: &Theme, tone: BadgeTone) -> Color32 {
let p = &theme.palette;
match tone {
BadgeTone::Ok => p.success,
BadgeTone::Warning => p.warning,
BadgeTone::Danger => p.danger,
BadgeTone::Info => p.sky,
BadgeTone::Neutral => p.text_muted,
}
}
fn apply_alpha(color: Color32, alpha: f32) -> Color32 {
let a = (color.a() as f32 * alpha.clamp(0.0, 1.0)).round() as u8;
Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a)
}
mod layout {
pub const PAD_X: f32 = 14.0;
pub const PAD_Y: f32 = 10.0;
pub const BAR_W: f32 = 3.0;
pub const BAR_GAP: f32 = 10.0;
pub const TITLE_DESC_GAP: f32 = 3.0;
pub const CLOSE_W: f32 = 18.0;
pub const CLOSE_GAP: f32 = 8.0;
pub const TEXT_LEFT_NUDGE: f32 = 4.0;
pub fn text_wrap_width(card_width: f32) -> f32 {
(card_width - PAD_X * 1.5 - BAR_W - BAR_GAP - CLOSE_W - CLOSE_GAP + TEXT_LEFT_NUDGE)
.max(1.0)
}
}
fn measure_height(ctx: &Context, theme: &Theme, entry: &ToastEntry, width: f32) -> f32 {
use layout::*;
let t = &theme.typography;
let text_width = text_wrap_width(width);
let title_galley = ctx.fonts_mut(|f| {
f.layout(
entry.title.clone(),
egui::FontId::proportional(t.body),
Color32::PLACEHOLDER,
text_width,
)
});
let mut h = PAD_Y * 2.0 + title_galley.size().y;
if let Some(desc) = &entry.description {
let desc_galley = ctx.fonts_mut(|f| {
f.layout(
desc.clone(),
egui::FontId::proportional(t.small),
Color32::PLACEHOLDER,
text_width,
)
});
h += TITLE_DESC_GAP + desc_galley.size().y;
}
h.max(44.0)
}
fn paint_toast(ui: &mut Ui, theme: &Theme, entry: &ToastEntry, rect: Rect, alpha: f32) -> bool {
use layout::*;
let p = &theme.palette;
let t = &theme.typography;
let role = match entry.tone {
BadgeTone::Danger | BadgeTone::Warning => accesskit::Role::Alert,
_ => accesskit::Role::Status,
};
let label = entry.title.clone();
let description = entry.description.clone();
ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
node.set_role(role);
node.set_label(label);
if let Some(d) = description {
node.set_description(d);
}
});
ui.allocate_rect(rect, Sense::hover());
let painter = ui.painter();
let bg = apply_alpha(p.depth_tint(p.card, 0.04), alpha);
let border = apply_alpha(p.border, alpha);
painter.rect(
rect,
CornerRadius::same(theme.card_radius as u8),
bg,
Stroke::new(1.0, border),
StrokeKind::Inside,
);
let accent = apply_alpha(tone_accent(theme, entry.tone), alpha);
let bar_rect = Rect::from_min_max(
Pos2::new(rect.min.x + 4.0, rect.min.y + 6.0),
Pos2::new(rect.min.x + 4.0 + BAR_W, rect.max.y - 6.0),
);
painter.rect_filled(bar_rect, CornerRadius::same(2), accent);
let close_rect = Rect::from_min_size(
Pos2::new(rect.max.x - PAD_X * 0.5 - CLOSE_W, rect.min.y + 6.0),
Vec2::new(CLOSE_W, CLOSE_W),
);
let close_resp: Response = ui.allocate_rect(close_rect, Sense::click());
let close_color = if close_resp.hovered() {
apply_alpha(p.text, alpha)
} else {
apply_alpha(p.text_muted, alpha)
};
let close_galley = crate::theme::placeholder_galley(ui, "×", t.body + 2.0, true, f32::INFINITY);
let close_text_pos = Pos2::new(
close_rect.center().x - close_galley.size().x * 0.5,
close_rect.center().y - close_galley.size().y * 0.5,
);
ui.painter()
.galley(close_text_pos, close_galley, close_color);
let text_left = rect.min.x + PAD_X + BAR_W + BAR_GAP - TEXT_LEFT_NUDGE;
let text_width = text_wrap_width(rect.width());
let title_color = apply_alpha(p.text, alpha);
let desc_color = apply_alpha(p.text_muted, alpha);
let title_galley = ui.ctx().fonts_mut(|f| {
f.layout(
entry.title.clone(),
egui::FontId::proportional(t.body),
Color32::PLACEHOLDER,
text_width,
)
});
let title_size_y = title_galley.size().y;
let title_pos = Pos2::new(text_left, rect.min.y + PAD_Y);
ui.painter().galley(title_pos, title_galley, title_color);
if let Some(desc) = &entry.description {
let desc_galley = ui.ctx().fonts_mut(|f| {
f.layout(
desc.clone(),
egui::FontId::proportional(t.small),
Color32::PLACEHOLDER,
text_width,
)
});
let desc_pos = Pos2::new(
text_left,
rect.min.y + PAD_Y + title_size_y + TITLE_DESC_GAP,
);
ui.painter().galley(desc_pos, desc_galley, desc_color);
}
close_resp.clicked()
}
fn paint_clear_all(ui: &mut Ui, theme: &Theme, rect: Rect) -> bool {
let p = &theme.palette;
let t = &theme.typography;
ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
node.set_role(accesskit::Role::Button);
node.set_label("Clear all notifications");
});
let resp = ui.allocate_rect(rect, Sense::click());
let painter = ui.painter();
let bg = if resp.hovered() {
p.depth_tint(p.card, 0.10)
} else {
p.depth_tint(p.card, 0.04)
};
let radius = CornerRadius::same((rect.height() * 0.5).round() as u8);
painter.rect(
rect,
radius,
bg,
Stroke::new(1.0, p.border),
StrokeKind::Inside,
);
let text_color = if resp.hovered() { p.text } else { p.text_muted };
let galley = crate::theme::placeholder_galley(ui, "Clear all", t.small, false, f32::INFINITY);
let text_pos = Pos2::new(
rect.center().x - galley.size().x * 0.5,
rect.center().y - galley.size().y * 0.5,
);
painter.galley(text_pos, galley, text_color);
resp.clicked()
}