use std::collections::VecDeque;
use std::time::Duration;
use egui::{Align2, Id, Order, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, text::LayoutJob, vec2};
use crate::automation::{self, Details, Role};
use crate::tokens::{self, Tokens};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToastLevel {
#[default]
Info,
Success,
Warning,
Error,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Toast {
pub level: ToastLevel,
pub text: String,
pub duration: Option<Duration>,
}
impl Toast {
pub fn new(level: ToastLevel, text: impl Into<String>) -> Self {
let duration = Some(if level == ToastLevel::Error { Duration::from_secs(8) } else { Duration::from_secs(4) });
Toast { level, text: text.into(), duration }
}
pub fn sticky(mut self) -> Self {
self.duration = None;
self
}
pub fn duration(mut self, d: Duration) -> Self {
self.duration = Some(d);
self
}
}
struct Entry {
id: u64,
toast: Toast,
remaining: Option<f64>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Corner {
TopRight,
#[default]
BottomRight,
BottomLeft,
TopLeft,
}
pub struct Toasts {
entries: VecDeque<Entry>,
next_id: u64,
capacity: usize,
max_visible: usize,
corner: Corner,
width: f32,
inset: Vec2,
last_time: Option<f64>,
}
impl Default for Toasts {
fn default() -> Self {
Self::new()
}
}
pub const MAX_TEXT: usize = 2000;
impl Toasts {
pub fn new() -> Self {
Toasts {
entries: VecDeque::new(),
next_id: 1,
capacity: 32,
max_visible: 4,
corner: Corner::default(),
width: 320.0,
inset: Vec2::ZERO,
last_time: None,
}
}
pub fn capacity(mut self, capacity: usize) -> Self {
self.capacity = capacity.clamp(1, 1024);
self
}
pub fn max_visible(mut self, n: usize) -> Self {
self.max_visible = n.clamp(1, 16);
self
}
pub fn corner(mut self, corner: Corner) -> Self {
self.corner = corner;
self
}
pub fn inset(mut self, inset: Vec2) -> Self {
let clean = |v: f32| if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 };
self.inset = Vec2::new(clean(inset.x), clean(inset.y));
self
}
pub fn width(mut self, width: f32) -> Self {
if width.is_finite() {
self.width = width.clamp(120.0, 1200.0);
}
self
}
pub fn push(&mut self, mut toast: Toast) -> u64 {
if toast.text.len() > MAX_TEXT {
let mut cut = MAX_TEXT;
while !toast.text.is_char_boundary(cut) {
cut -= 1;
}
toast.text.truncate(cut);
toast.text.push('…');
}
while self.entries.len() >= self.capacity {
self.entries.pop_front();
}
let id = self.next_id;
self.next_id = self.next_id.wrapping_add(1);
let remaining = toast.duration.map(|d| d.as_secs_f64().min(3600.0));
self.entries.push_back(Entry { id, toast, remaining });
id
}
pub fn info(&mut self, text: impl Into<String>) -> u64 {
self.push(Toast::new(ToastLevel::Info, text))
}
pub fn success(&mut self, text: impl Into<String>) -> u64 {
self.push(Toast::new(ToastLevel::Success, text))
}
pub fn warning(&mut self, text: impl Into<String>) -> u64 {
self.push(Toast::new(ToastLevel::Warning, text))
}
pub fn error(&mut self, text: impl Into<String>) -> u64 {
self.push(Toast::new(ToastLevel::Error, text))
}
pub fn dismiss(&mut self, id: u64) -> bool {
let before = self.entries.len();
self.entries.retain(|e| e.id != id);
before != self.entries.len()
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn texts(&self) -> impl Iterator<Item = &str> {
self.entries.iter().map(|e| e.toast.text.as_str())
}
pub fn show(&mut self, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let dt = self.last_time.map_or(0.0, |last| (now - last).clamp(0.0, 1.0));
self.last_time = Some(now);
if self.entries.is_empty() {
self.last_time = None;
return;
}
let t = tokens::current(ctx);
let screen = ctx.content_rect();
let margin = t.space.lg;
let (anchor, down) = match self.corner {
Corner::TopRight => (Align2::RIGHT_TOP, true),
Corner::BottomRight => (Align2::RIGHT_BOTTOM, false),
Corner::BottomLeft => (Align2::LEFT_BOTTOM, false),
Corner::TopLeft => (Align2::LEFT_TOP, true),
};
let width = self.width.min((screen.width() - 2.0 * margin).max(80.0));
let mut offset = 0.0f32;
let mut dismiss = None;
let mut next_expiry: Option<f64> = None;
let visible = self.entries.len().min(self.max_visible);
for entry in self.entries.iter_mut().take(visible) {
let area_id = Id::new(("ashlar_ui::toast", entry.id));
let y = if down { offset } else { -offset };
let x = if matches!(anchor, Align2::RIGHT_TOP | Align2::RIGHT_BOTTOM) { -margin - self.inset.x } else { margin + self.inset.x };
let y_margin = if down { margin + self.inset.y } else { -margin - self.inset.y };
let area = egui::Area::new(area_id).order(Order::Foreground).anchor(anchor, vec2(x, y + y_margin)).interactable(true);
let mut hovered = false;
let inner = area.show(ctx, |ui| {
ui.set_width(width);
let (closed, h) = paint_toast(ui, &t, entry);
hovered = ui.rect_contains_pointer(ui.min_rect());
(closed, h)
});
let (closed, height) = inner.inner;
offset += height + t.space.sm;
if closed {
dismiss = Some(entry.id);
}
if let Some(rem) = entry.remaining.as_mut() {
if !hovered {
*rem -= dt;
}
if *rem <= 0.0 {
dismiss.get_or_insert(entry.id);
} else {
next_expiry = Some(next_expiry.map_or(*rem, |n: f64| n.min(*rem)));
}
}
}
self.entries.retain(|e| Some(e.id) != dismiss && e.remaining.is_none_or(|r| r > 0.0));
if dismiss.is_some() {
crate::wake::wake(ctx, crate::wake::Reason::INPUT);
} else if let Some(secs) = next_expiry {
crate::wake::wake_after(ctx, crate::wake::Reason::TIMER, Duration::from_secs_f64(secs.max(0.016)));
}
}
}
fn paint_toast(ui: &mut Ui, t: &Tokens, entry: &Entry) -> (bool, f32) {
let accent = match entry.toast.level {
ToastLevel::Info => t.status.info,
ToastLevel::Success => t.status.success,
ToastLevel::Warning => t.status.warning,
ToastLevel::Error => t.status.danger,
};
let width = ui.available_width();
let pad = t.space.md;
let close_w = t.control_height;
let text_w = (width - 2.0 * pad - close_w - 6.0).max(40.0);
let font = Tokens::font(t.type_scale.body);
let revision = crate::cache::hash_of(&(text_w.to_bits(), font.size.to_bits(), t.text.primary));
let galley = crate::cache::galley(ui, ("ashlar_ui::toast", entry.id), revision, || {
LayoutJob::simple(entry.toast.text.clone(), font, t.text.primary, text_w)
});
let height = (galley.size().y + 2.0 * pad).max(t.control_height + pad);
let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
let painter = ui.painter();
painter.add(egui::Shape::Rect(egui::epaint::RectShape::filled(rect, t.radius.lg, t.surface.raised)));
painter.rect_stroke(rect, t.radius.lg, Stroke::new(1.0, t.border), StrokeKind::Inside);
let bar = Rect::from_min_size(rect.min, vec2(4.0, rect.height()));
painter.rect_filled(bar, egui::CornerRadius { nw: t.radius.lg, sw: t.radius.lg, ne: 0, se: 0 }, accent);
painter.galley(pos2(rect.left() + pad + 6.0, rect.top() + pad), galley, t.text.primary);
let close_rect = Rect::from_center_size(pos2(rect.right() - pad - close_w / 2.0 + 4.0, rect.top() + pad + 8.0), Vec2::splat(close_w * 0.8));
let close = ui.interact(close_rect, resp.id.with("close"), Sense::click());
let c = close_rect.center();
let k = close_rect.width() * 0.22;
let col = if close.hovered() { t.text.primary } else { t.text.secondary };
if close.hovered() {
ui.painter().rect_filled(close_rect, t.radius.sm, t.surface.hover);
}
ui.painter().line_segment([c - vec2(k, k), c + vec2(k, k)], Stroke::new(1.5, col));
ui.painter().line_segment([c + vec2(-k, k), c + vec2(k, -k)], Stroke::new(1.5, col));
let id = entry.id;
let level = level_name(entry.toast.level);
automation::register_fmt(
ui,
rect,
format_args!("toast.{id}"),
&entry.toast.text,
Role::Toast,
true,
Details { value: Some(level), selected: None },
);
automation::register_fmt(ui, close_rect, format_args!("toast.{id}.close"), "Dismiss", Role::Button, true, Details::default());
close.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Dismiss"));
(close.clicked(), height)
}
fn level_name(l: ToastLevel) -> &'static str {
match l {
ToastLevel::Info => "info",
ToastLevel::Success => "success",
ToastLevel::Warning => "warning",
ToastLevel::Error => "error",
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::Harness;
#[test]
fn queue_is_bounded_and_text_is_capped() {
let mut q = Toasts::new().capacity(3);
for i in 0..10 {
q.info(format!("n{i}"));
}
assert_eq!(q.texts().collect::<Vec<_>>(), ["n7", "n8", "n9"]);
let id = q.push(Toast::new(ToastLevel::Error, "é".repeat(MAX_TEXT)));
assert!(q.texts().last().unwrap().len() <= MAX_TEXT + 3);
assert!(q.dismiss(id));
assert!(!q.dismiss(id));
}
#[test]
fn inset_keeps_the_stack_clear_of_the_edges() {
let shown = |toasts: Toasts| {
let mut h = Harness::builder().with_size(vec2(600.0, 400.0)).build_ui_state(|ui, q: &mut Toasts| q.show(ui.ctx()), toasts);
automation::install(&h.ctx);
h.state_mut().info("Saved");
h.step();
h.step();
automation::query(&h.ctx, "toast.")
.into_iter()
.find(|e| e.label == "Saved")
.map(|e| (e.rect[0] + e.rect[2], e.rect[1] + e.rect[3]))
.unwrap()
};
let plain = shown(Toasts::new());
let inset = shown(Toasts::new().inset(vec2(10.0, 30.0)));
assert!((plain.0 - inset.0 - 10.0).abs() < 0.5, "{plain:?} {inset:?}");
assert!((plain.1 - inset.1 - 30.0).abs() < 0.5, "{plain:?} {inset:?}");
let hostile = shown(Toasts::new().inset(vec2(f32::NAN, -50.0)));
assert_eq!(hostile, plain);
}
#[test]
fn timed_toasts_expire_sticky_ones_stay_and_close_dismisses() {
let mut h = Harness::builder().with_size(vec2(600.0, 400.0)).with_step_dt(0.5).build_ui_state(
|ui, q: &mut Toasts| {
q.show(ui.ctx());
},
Toasts::new(),
);
automation::install(&h.ctx);
h.state_mut().push(Toast::new(ToastLevel::Info, "Saved").duration(Duration::from_secs(1)));
let sticky = h.state_mut().push(Toast::new(ToastLevel::Warning, "Disk almost full").sticky());
h.step();
let saved = automation::query(&h.ctx, "toast.").into_iter().find(|e| e.label == "Saved").unwrap();
assert_eq!(saved.value, "info");
assert!(h.ctx.content_rect().contains(saved.center()));
for _ in 0..4 {
h.step();
}
assert_eq!(h.state().texts().collect::<Vec<_>>(), ["Disk almost full"]);
let close = automation::find(&h.ctx, &format!("toast.{sticky}.close")).unwrap();
h.hover_at(close.center());
h.step();
h.drag_at(close.center());
h.step();
h.drop_at(close.center());
h.step();
assert!(h.state().is_empty());
h.run();
}
}