1use std::{collections::VecDeque, time::Duration};
29
30use egui::{
31 accesskit, Align2, Area, Color32, Context, CornerRadius, Id, Order, Pos2, Rect, Response,
32 Sense, Stroke, StrokeKind, Ui, Vec2,
33};
34
35use crate::theme::Theme;
36use crate::BadgeTone;
37
38const FADE_OUT: f64 = 0.20;
42const DEFAULT_DURATION: f64 = 4.0;
44const DEFAULT_MAX_VISIBLE: usize = 5;
46const DEFAULT_WIDTH: f32 = 320.0;
48const STACK_GAP: f32 = 8.0;
50
51fn storage_id() -> Id {
52 Id::new("elegance::toasts")
53}
54
55#[derive(Debug, Clone)]
61#[must_use = "Call `show(ctx)` to enqueue the toast."]
62pub struct Toast {
63 title: String,
64 description: Option<String>,
65 tone: BadgeTone,
66 duration: Option<Duration>,
67}
68
69impl Toast {
70 pub fn new(title: impl Into<String>) -> Self {
73 Self {
74 title: title.into(),
75 description: None,
76 tone: BadgeTone::Info,
77 duration: Some(Duration::from_secs_f64(DEFAULT_DURATION)),
78 }
79 }
80
81 pub fn tone(mut self, tone: BadgeTone) -> Self {
83 self.tone = tone;
84 self
85 }
86
87 pub fn description(mut self, description: impl Into<String>) -> Self {
89 self.description = Some(description.into());
90 self
91 }
92
93 pub fn duration(mut self, duration: Duration) -> Self {
95 self.duration = Some(duration);
96 self
97 }
98
99 pub fn persistent(mut self) -> Self {
102 self.duration = None;
103 self
104 }
105
106 pub fn show(self, ctx: &Context) {
109 let now = ctx.input(|i| i.time);
110 ctx.data_mut(|d| {
111 let mut state = d.get_temp::<ToastState>(storage_id()).unwrap_or_default();
112 let id = state.next_id;
113 state.next_id = state.next_id.wrapping_add(1);
114 state.queue.push_back(ToastEntry {
115 id,
116 title: self.title,
117 description: self.description,
118 tone: self.tone,
119 duration: self.duration.map(|d| d.as_secs_f64()),
120 birth: now,
121 dismiss_start: None,
122 });
123 d.insert_temp(storage_id(), state);
124 });
125 ctx.request_repaint();
126 }
127}
128
129#[derive(Debug, Clone)]
135#[must_use = "Call `.render(ctx)` to draw the toast stack."]
136pub struct Toasts {
137 anchor: Align2,
138 offset: Vec2,
139 max_visible: usize,
140 width: f32,
141}
142
143impl Default for Toasts {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149impl Toasts {
150 pub fn new() -> Self {
153 Self {
154 anchor: Align2::RIGHT_BOTTOM,
155 offset: Vec2::new(12.0, 12.0),
156 max_visible: DEFAULT_MAX_VISIBLE,
157 width: DEFAULT_WIDTH,
158 }
159 }
160
161 pub fn anchor(mut self, anchor: Align2) -> Self {
163 self.anchor = anchor;
164 self
165 }
166
167 pub fn offset(mut self, offset: impl Into<Vec2>) -> Self {
169 self.offset = offset.into();
170 self
171 }
172
173 pub fn max_visible(mut self, max_visible: usize) -> Self {
176 self.max_visible = max_visible.max(1);
177 self
178 }
179
180 pub fn width(mut self, width: f32) -> Self {
182 self.width = width.max(120.0);
183 self
184 }
185
186 pub fn render(self, ctx: &Context) {
188 let theme = Theme::current(ctx);
189 let now = ctx.input(|i| i.time);
190
191 let mut state = ctx
193 .data_mut(|d| d.get_temp::<ToastState>(storage_id()))
194 .unwrap_or_default();
195
196 state.queue.retain(|entry| !entry.is_expired(now));
199 while state.queue.len() > self.max_visible {
200 state.queue.pop_front();
201 }
202
203 if state.queue.is_empty() {
204 ctx.data_mut(|d| d.insert_temp(storage_id(), state));
205 return;
206 }
207
208 let screen = ctx.content_rect();
211 let stack_up = matches!(self.anchor.y(), egui::Align::Max);
212
213 let entry_heights: Vec<f32> = state
216 .queue
217 .iter()
218 .map(|e| measure_height(ctx, &theme, e, self.width))
219 .collect();
220
221 let x = match self.anchor.x() {
223 egui::Align::Min => screen.min.x + self.offset.x,
224 egui::Align::Center => screen.center().x - self.width * 0.5,
225 egui::Align::Max => screen.max.x - self.offset.x - self.width,
226 };
227
228 let (mut y, step_sign): (f32, f32) = if stack_up {
230 (screen.max.y - self.offset.y, -1.0)
231 } else {
232 (screen.min.y + self.offset.y, 1.0)
233 };
234
235 let order_is_new_to_old = stack_up;
237 let indices: Vec<usize> = if order_is_new_to_old {
238 (0..state.queue.len()).rev().collect()
239 } else {
240 (0..state.queue.len()).collect()
241 };
242
243 let mut dismiss_ids: Vec<u64> = Vec::new();
244 let mut earliest_next_event: Option<f64> = None;
245 let mut any_animating = false;
246
247 for i in indices {
248 let entry = &state.queue[i];
249 let h = entry_heights[i];
250
251 let (top, bottom) = if step_sign < 0.0 {
252 (y - h, y)
253 } else {
254 (y, y + h)
255 };
256 let rect = Rect::from_min_max(Pos2::new(x, top), Pos2::new(x + self.width, bottom));
257
258 let (alpha, is_animating, next_event) = entry.alpha_and_schedule(now);
260 any_animating |= is_animating;
261 if let Some(t) = next_event {
262 earliest_next_event = Some(match earliest_next_event {
263 Some(prev) => prev.min(t),
264 None => t,
265 });
266 }
267
268 let area_id = Id::new(("elegance::toast", entry.id));
269 let resp = Area::new(area_id)
270 .order(Order::Tooltip)
271 .fixed_pos(rect.min)
272 .show(ctx, |ui| paint_toast(ui, &theme, entry, rect, alpha));
273
274 if resp.inner {
275 dismiss_ids.push(entry.id);
276 }
277
278 let delta = (h + STACK_GAP) * step_sign;
280 y += delta;
281 }
282
283 if !dismiss_ids.is_empty() {
286 for entry in state.queue.iter_mut() {
287 if dismiss_ids.contains(&entry.id) && entry.dismiss_start.is_none() {
288 entry.dismiss_start = Some(now);
289 }
290 }
291 }
292
293 ctx.data_mut(|d| d.insert_temp(storage_id(), state));
294
295 if any_animating {
297 ctx.request_repaint();
298 } else if let Some(at) = earliest_next_event {
299 let remaining = (at - now).max(0.0);
300 ctx.request_repaint_after(Duration::from_secs_f64(remaining));
301 }
302 }
303}
304
305#[derive(Clone, Default)]
308struct ToastState {
309 queue: VecDeque<ToastEntry>,
310 next_id: u64,
311}
312
313#[derive(Clone)]
314struct ToastEntry {
315 id: u64,
316 title: String,
317 description: Option<String>,
318 tone: BadgeTone,
319 duration: Option<f64>,
321 birth: f64,
323 dismiss_start: Option<f64>,
325}
326
327impl ToastEntry {
328 fn is_expired(&self, now: f64) -> bool {
330 if let Some(ds) = self.dismiss_start {
331 return now >= ds + FADE_OUT;
332 }
333 if let Some(d) = self.duration {
334 return now >= self.birth + d + FADE_OUT;
335 }
336 false
337 }
338
339 fn alpha_and_schedule(&self, now: f64) -> (f32, bool, Option<f64>) {
347 let fade_out_start = match self.dismiss_start {
349 Some(ds) => Some(ds),
350 None => self.duration.map(|d| self.birth + d),
351 };
352
353 match fade_out_start {
354 Some(t0) if now >= t0 => {
355 let progress = ((now - t0) / FADE_OUT).clamp(0.0, 1.0) as f32;
356 (1.0 - progress, progress < 1.0, None)
357 }
358 Some(t0) => (1.0, false, Some(t0)),
359 None => (1.0, false, None),
360 }
361 }
362}
363
364fn tone_accent(theme: &Theme, tone: BadgeTone) -> Color32 {
365 let p = &theme.palette;
366 match tone {
367 BadgeTone::Ok => p.success,
368 BadgeTone::Warning => p.warning,
369 BadgeTone::Danger => p.danger,
370 BadgeTone::Info => p.sky,
371 BadgeTone::Neutral => p.text_muted,
372 }
373}
374
375fn apply_alpha(color: Color32, alpha: f32) -> Color32 {
376 let a = (color.a() as f32 * alpha.clamp(0.0, 1.0)).round() as u8;
377 Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a)
378}
379
380mod layout {
382 pub const PAD_X: f32 = 14.0;
383 pub const PAD_Y: f32 = 10.0;
384 pub const BAR_W: f32 = 3.0;
385 pub const BAR_GAP: f32 = 10.0;
386 pub const TITLE_DESC_GAP: f32 = 3.0;
387 pub const CLOSE_W: f32 = 18.0;
388 pub const CLOSE_GAP: f32 = 8.0;
389 pub const TEXT_LEFT_NUDGE: f32 = 4.0;
390
391 pub fn text_wrap_width(card_width: f32) -> f32 {
395 (card_width - PAD_X * 1.5 - BAR_W - BAR_GAP - CLOSE_W - CLOSE_GAP + TEXT_LEFT_NUDGE)
396 .max(1.0)
397 }
398}
399
400fn measure_height(ctx: &Context, theme: &Theme, entry: &ToastEntry, width: f32) -> f32 {
401 use layout::*;
402 let t = &theme.typography;
403
404 let text_width = text_wrap_width(width);
409 let title_galley = ctx.fonts_mut(|f| {
410 f.layout(
411 entry.title.clone(),
412 egui::FontId::proportional(t.body),
413 Color32::PLACEHOLDER,
414 text_width,
415 )
416 });
417
418 let mut h = PAD_Y * 2.0 + title_galley.size().y;
419 if let Some(desc) = &entry.description {
420 let desc_galley = ctx.fonts_mut(|f| {
421 f.layout(
422 desc.clone(),
423 egui::FontId::proportional(t.small),
424 Color32::PLACEHOLDER,
425 text_width,
426 )
427 });
428 h += TITLE_DESC_GAP + desc_galley.size().y;
429 }
430 h.max(44.0)
431}
432
433fn paint_toast(ui: &mut Ui, theme: &Theme, entry: &ToastEntry, rect: Rect, alpha: f32) -> bool {
436 use layout::*;
437 let p = &theme.palette;
438 let t = &theme.typography;
439
440 let role = match entry.tone {
445 BadgeTone::Danger | BadgeTone::Warning => accesskit::Role::Alert,
446 _ => accesskit::Role::Status,
447 };
448 let label = entry.title.clone();
449 let description = entry.description.clone();
450 ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
451 node.set_role(role);
452 node.set_label(label);
453 if let Some(d) = description {
454 node.set_description(d);
455 }
456 });
457
458 ui.allocate_rect(rect, Sense::hover());
460 let painter = ui.painter();
461
462 let bg = apply_alpha(p.depth_tint(p.card, 0.04), alpha);
464 let border = apply_alpha(p.border, alpha);
465 painter.rect(
466 rect,
467 CornerRadius::same(theme.card_radius as u8),
468 bg,
469 Stroke::new(1.0, border),
470 StrokeKind::Inside,
471 );
472
473 let accent = apply_alpha(tone_accent(theme, entry.tone), alpha);
475 let bar_rect = Rect::from_min_max(
476 Pos2::new(rect.min.x + 4.0, rect.min.y + 6.0),
477 Pos2::new(rect.min.x + 4.0 + BAR_W, rect.max.y - 6.0),
478 );
479 painter.rect_filled(bar_rect, CornerRadius::same(2), accent);
480
481 let close_rect = Rect::from_min_size(
483 Pos2::new(rect.max.x - PAD_X * 0.5 - CLOSE_W, rect.min.y + 6.0),
484 Vec2::new(CLOSE_W, CLOSE_W),
485 );
486 let close_resp: Response = ui.allocate_rect(close_rect, Sense::click());
487 let close_color = if close_resp.hovered() {
488 apply_alpha(p.text, alpha)
489 } else {
490 apply_alpha(p.text_muted, alpha)
491 };
492 let close_galley = crate::theme::placeholder_galley(ui, "×", t.body + 2.0, true, f32::INFINITY);
493 let close_text_pos = Pos2::new(
494 close_rect.center().x - close_galley.size().x * 0.5,
495 close_rect.center().y - close_galley.size().y * 0.5,
496 );
497 ui.painter()
498 .galley(close_text_pos, close_galley, close_color);
499
500 let text_left = rect.min.x + PAD_X + BAR_W + BAR_GAP - TEXT_LEFT_NUDGE;
502 let text_width = text_wrap_width(rect.width());
503
504 let title_color = apply_alpha(p.text, alpha);
505 let desc_color = apply_alpha(p.text_muted, alpha);
506
507 let title_galley = ui.ctx().fonts_mut(|f| {
511 f.layout(
512 entry.title.clone(),
513 egui::FontId::proportional(t.body),
514 Color32::PLACEHOLDER,
515 text_width,
516 )
517 });
518 let title_size_y = title_galley.size().y;
519 let title_pos = Pos2::new(text_left, rect.min.y + PAD_Y);
520 ui.painter().galley(title_pos, title_galley, title_color);
521
522 if let Some(desc) = &entry.description {
523 let desc_galley = ui.ctx().fonts_mut(|f| {
524 f.layout(
525 desc.clone(),
526 egui::FontId::proportional(t.small),
527 Color32::PLACEHOLDER,
528 text_width,
529 )
530 });
531 let desc_pos = Pos2::new(
532 text_left,
533 rect.min.y + PAD_Y + title_size_y + TITLE_DESC_GAP,
534 );
535 ui.painter().galley(desc_pos, desc_galley, desc_color);
536 }
537
538 close_resp.clicked()
539}