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;
50const CLEAR_ALL_HEIGHT: f32 = 26.0;
52const CLEAR_ALL_GAP: f32 = 6.0;
54const CLEAR_ALL_THRESHOLD: usize = 2;
58
59fn storage_id() -> Id {
60 Id::new("elegance::toasts")
61}
62
63#[derive(Debug, Clone)]
69#[must_use = "Call `show(ctx)` to enqueue the toast."]
70pub struct Toast {
71 title: String,
72 description: Option<String>,
73 tone: BadgeTone,
74 duration: Option<Duration>,
75}
76
77impl Toast {
78 pub fn new(title: impl Into<String>) -> Self {
81 Self {
82 title: title.into(),
83 description: None,
84 tone: BadgeTone::Info,
85 duration: Some(Duration::from_secs_f64(DEFAULT_DURATION)),
86 }
87 }
88
89 pub fn tone(mut self, tone: BadgeTone) -> Self {
91 self.tone = tone;
92 self
93 }
94
95 pub fn description(mut self, description: impl Into<String>) -> Self {
97 self.description = Some(description.into());
98 self
99 }
100
101 pub fn duration(mut self, duration: Duration) -> Self {
103 self.duration = Some(duration);
104 self
105 }
106
107 pub fn persistent(mut self) -> Self {
110 self.duration = None;
111 self
112 }
113
114 pub fn show(self, ctx: &Context) {
117 let now = ctx.input(|i| i.time);
118 ctx.data_mut(|d| {
119 let mut state = d.get_temp::<ToastState>(storage_id()).unwrap_or_default();
120 let id = state.next_id;
121 state.next_id = state.next_id.wrapping_add(1);
122 state.queue.push_back(ToastEntry {
123 id,
124 title: self.title,
125 description: self.description,
126 tone: self.tone,
127 duration: self.duration.map(|d| d.as_secs_f64()),
128 birth: now,
129 dismiss_start: None,
130 });
131 d.insert_temp(storage_id(), state);
132 });
133 ctx.request_repaint();
134 }
135}
136
137#[derive(Debug, Clone)]
143#[must_use = "Call `.render(ctx)` to draw the toast stack."]
144pub struct Toasts {
145 anchor: Align2,
146 offset: Vec2,
147 max_visible: usize,
148 width: f32,
149 clear_all_button: bool,
150}
151
152impl Default for Toasts {
153 fn default() -> Self {
154 Self::new()
155 }
156}
157
158impl Toasts {
159 pub fn new() -> Self {
162 Self {
163 anchor: Align2::RIGHT_BOTTOM,
164 offset: Vec2::new(12.0, 12.0),
165 max_visible: DEFAULT_MAX_VISIBLE,
166 width: DEFAULT_WIDTH,
167 clear_all_button: false,
168 }
169 }
170
171 pub fn anchor(mut self, anchor: Align2) -> Self {
173 self.anchor = anchor;
174 self
175 }
176
177 pub fn offset(mut self, offset: impl Into<Vec2>) -> Self {
179 self.offset = offset.into();
180 self
181 }
182
183 pub fn max_visible(mut self, max_visible: usize) -> Self {
186 self.max_visible = max_visible.max(1);
187 self
188 }
189
190 pub fn width(mut self, width: f32) -> Self {
192 self.width = width.max(120.0);
193 self
194 }
195
196 pub fn clear_all_button(mut self, enabled: bool) -> Self {
202 self.clear_all_button = enabled;
203 self
204 }
205
206 pub fn render(self, ctx: &Context) {
208 let theme = Theme::current(ctx);
209 let now = ctx.input(|i| i.time);
210
211 let mut state = ctx
213 .data_mut(|d| d.get_temp::<ToastState>(storage_id()))
214 .unwrap_or_default();
215
216 state.queue.retain(|entry| !entry.is_expired(now));
219 while state.queue.len() > self.max_visible {
220 state.queue.pop_front();
221 }
222
223 if state.queue.is_empty() {
224 ctx.data_mut(|d| d.insert_temp(storage_id(), state));
225 return;
226 }
227
228 let screen = ctx.content_rect();
231 let stack_up = matches!(self.anchor.y(), egui::Align::Max);
232
233 let entry_heights: Vec<f32> = state
236 .queue
237 .iter()
238 .map(|e| measure_height(ctx, &theme, e, self.width))
239 .collect();
240
241 let x = match self.anchor.x() {
243 egui::Align::Min => screen.min.x + self.offset.x,
244 egui::Align::Center => screen.center().x - self.width * 0.5,
245 egui::Align::Max => screen.max.x - self.offset.x - self.width,
246 };
247
248 let (mut y, step_sign): (f32, f32) = if stack_up {
250 (screen.max.y - self.offset.y, -1.0)
251 } else {
252 (screen.min.y + self.offset.y, 1.0)
253 };
254
255 let order_is_new_to_old = stack_up;
257 let indices: Vec<usize> = if order_is_new_to_old {
258 (0..state.queue.len()).rev().collect()
259 } else {
260 (0..state.queue.len()).collect()
261 };
262
263 let mut dismiss_ids: Vec<u64> = Vec::new();
264 let mut earliest_next_event: Option<f64> = None;
265 let mut any_animating = false;
266
267 for i in indices {
268 let entry = &state.queue[i];
269 let h = entry_heights[i];
270
271 let (top, bottom) = if step_sign < 0.0 {
272 (y - h, y)
273 } else {
274 (y, y + h)
275 };
276 let rect = Rect::from_min_max(Pos2::new(x, top), Pos2::new(x + self.width, bottom));
277
278 let (alpha, is_animating, next_event) = entry.alpha_and_schedule(now);
280 any_animating |= is_animating;
281 if let Some(t) = next_event {
282 earliest_next_event = Some(match earliest_next_event {
283 Some(prev) => prev.min(t),
284 None => t,
285 });
286 }
287
288 let area_id = Id::new(("elegance::toast", entry.id));
289 let resp = Area::new(area_id)
290 .order(Order::Tooltip)
291 .fixed_pos(rect.min)
292 .show(ctx, |ui| paint_toast(ui, &theme, entry, rect, alpha));
293
294 if resp.inner {
295 dismiss_ids.push(entry.id);
296 }
297
298 let delta = (h + STACK_GAP) * step_sign;
300 y += delta;
301 }
302
303 if !dismiss_ids.is_empty() {
306 for entry in state.queue.iter_mut() {
307 if dismiss_ids.contains(&entry.id) && entry.dismiss_start.is_none() {
308 entry.dismiss_start = Some(now);
309 }
310 }
311 }
312
313 let active_count = state
317 .queue
318 .iter()
319 .filter(|e| e.dismiss_start.is_none())
320 .count();
321 if self.clear_all_button && active_count >= CLEAR_ALL_THRESHOLD {
322 let total_h: f32 = entry_heights.iter().sum::<f32>()
323 + STACK_GAP * entry_heights.len().saturating_sub(1) as f32;
324 let pill_top = if stack_up {
325 (screen.max.y - self.offset.y) - total_h - CLEAR_ALL_GAP - CLEAR_ALL_HEIGHT
326 } else {
327 (screen.min.y + self.offset.y) + total_h + CLEAR_ALL_GAP
328 };
329 let pill_rect = Rect::from_min_size(
330 Pos2::new(x, pill_top),
331 Vec2::new(self.width, CLEAR_ALL_HEIGHT),
332 );
333
334 let area_id = Id::new("elegance::toast::clear_all");
335 let resp = Area::new(area_id)
336 .order(Order::Tooltip)
337 .fixed_pos(pill_rect.min)
338 .show(ctx, |ui| paint_clear_all(ui, &theme, pill_rect));
339
340 if resp.inner {
341 for entry in state.queue.iter_mut() {
342 if entry.dismiss_start.is_none() {
343 entry.dismiss_start = Some(now);
344 }
345 }
346 any_animating = true;
347 }
348 }
349
350 ctx.data_mut(|d| d.insert_temp(storage_id(), state));
351
352 if any_animating {
354 ctx.request_repaint();
355 } else if let Some(at) = earliest_next_event {
356 let remaining = (at - now).max(0.0);
357 ctx.request_repaint_after(Duration::from_secs_f64(remaining));
358 }
359 }
360}
361
362#[derive(Clone, Default)]
365struct ToastState {
366 queue: VecDeque<ToastEntry>,
367 next_id: u64,
368}
369
370#[derive(Clone)]
371struct ToastEntry {
372 id: u64,
373 title: String,
374 description: Option<String>,
375 tone: BadgeTone,
376 duration: Option<f64>,
378 birth: f64,
380 dismiss_start: Option<f64>,
382}
383
384impl ToastEntry {
385 fn is_expired(&self, now: f64) -> bool {
387 if let Some(ds) = self.dismiss_start {
388 return now >= ds + FADE_OUT;
389 }
390 if let Some(d) = self.duration {
391 return now >= self.birth + d + FADE_OUT;
392 }
393 false
394 }
395
396 fn alpha_and_schedule(&self, now: f64) -> (f32, bool, Option<f64>) {
404 let fade_out_start = match self.dismiss_start {
406 Some(ds) => Some(ds),
407 None => self.duration.map(|d| self.birth + d),
408 };
409
410 match fade_out_start {
411 Some(t0) if now >= t0 => {
412 let progress = ((now - t0) / FADE_OUT).clamp(0.0, 1.0) as f32;
413 (1.0 - progress, progress < 1.0, None)
414 }
415 Some(t0) => (1.0, false, Some(t0)),
416 None => (1.0, false, None),
417 }
418 }
419}
420
421fn tone_accent(theme: &Theme, tone: BadgeTone) -> Color32 {
422 let p = &theme.palette;
423 match tone {
424 BadgeTone::Ok => p.success,
425 BadgeTone::Warning => p.warning,
426 BadgeTone::Danger => p.danger,
427 BadgeTone::Info => p.sky,
428 BadgeTone::Neutral => p.text_muted,
429 }
430}
431
432fn apply_alpha(color: Color32, alpha: f32) -> Color32 {
433 let a = (color.a() as f32 * alpha.clamp(0.0, 1.0)).round() as u8;
434 Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a)
435}
436
437mod layout {
439 pub const PAD_X: f32 = 14.0;
440 pub const PAD_Y: f32 = 10.0;
441 pub const BAR_W: f32 = 3.0;
442 pub const BAR_GAP: f32 = 10.0;
443 pub const TITLE_DESC_GAP: f32 = 3.0;
444 pub const CLOSE_W: f32 = 18.0;
445 pub const CLOSE_GAP: f32 = 8.0;
446 pub const TEXT_LEFT_NUDGE: f32 = 4.0;
447
448 pub fn text_wrap_width(card_width: f32) -> f32 {
452 (card_width - PAD_X * 1.5 - BAR_W - BAR_GAP - CLOSE_W - CLOSE_GAP + TEXT_LEFT_NUDGE)
453 .max(1.0)
454 }
455}
456
457fn measure_height(ctx: &Context, theme: &Theme, entry: &ToastEntry, width: f32) -> f32 {
458 use layout::*;
459 let t = &theme.typography;
460
461 let text_width = text_wrap_width(width);
466 let title_galley = ctx.fonts_mut(|f| {
467 f.layout(
468 entry.title.clone(),
469 egui::FontId::proportional(t.body),
470 Color32::PLACEHOLDER,
471 text_width,
472 )
473 });
474
475 let mut h = PAD_Y * 2.0 + title_galley.size().y;
476 if let Some(desc) = &entry.description {
477 let desc_galley = ctx.fonts_mut(|f| {
478 f.layout(
479 desc.clone(),
480 egui::FontId::proportional(t.small),
481 Color32::PLACEHOLDER,
482 text_width,
483 )
484 });
485 h += TITLE_DESC_GAP + desc_galley.size().y;
486 }
487 h.max(44.0)
488}
489
490fn paint_toast(ui: &mut Ui, theme: &Theme, entry: &ToastEntry, rect: Rect, alpha: f32) -> bool {
493 use layout::*;
494 let p = &theme.palette;
495 let t = &theme.typography;
496
497 let role = match entry.tone {
502 BadgeTone::Danger | BadgeTone::Warning => accesskit::Role::Alert,
503 _ => accesskit::Role::Status,
504 };
505 let label = entry.title.clone();
506 let description = entry.description.clone();
507 ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
508 node.set_role(role);
509 node.set_label(label);
510 if let Some(d) = description {
511 node.set_description(d);
512 }
513 });
514
515 ui.allocate_rect(rect, Sense::hover());
517 let painter = ui.painter();
518
519 let bg = apply_alpha(p.depth_tint(p.card, 0.04), alpha);
521 let border = apply_alpha(p.border, alpha);
522 painter.rect(
523 rect,
524 CornerRadius::same(theme.card_radius as u8),
525 bg,
526 Stroke::new(1.0, border),
527 StrokeKind::Inside,
528 );
529
530 let accent = apply_alpha(tone_accent(theme, entry.tone), alpha);
532 let bar_rect = Rect::from_min_max(
533 Pos2::new(rect.min.x + 4.0, rect.min.y + 6.0),
534 Pos2::new(rect.min.x + 4.0 + BAR_W, rect.max.y - 6.0),
535 );
536 painter.rect_filled(bar_rect, CornerRadius::same(2), accent);
537
538 let close_rect = Rect::from_min_size(
540 Pos2::new(rect.max.x - PAD_X * 0.5 - CLOSE_W, rect.min.y + 6.0),
541 Vec2::new(CLOSE_W, CLOSE_W),
542 );
543 let close_resp: Response = ui.allocate_rect(close_rect, Sense::click());
544 let close_color = if close_resp.hovered() {
545 apply_alpha(p.text, alpha)
546 } else {
547 apply_alpha(p.text_muted, alpha)
548 };
549 let close_galley = crate::theme::placeholder_galley(ui, "×", t.body + 2.0, true, f32::INFINITY);
550 let close_text_pos = Pos2::new(
551 close_rect.center().x - close_galley.size().x * 0.5,
552 close_rect.center().y - close_galley.size().y * 0.5,
553 );
554 ui.painter()
555 .galley(close_text_pos, close_galley, close_color);
556
557 let text_left = rect.min.x + PAD_X + BAR_W + BAR_GAP - TEXT_LEFT_NUDGE;
559 let text_width = text_wrap_width(rect.width());
560
561 let title_color = apply_alpha(p.text, alpha);
562 let desc_color = apply_alpha(p.text_muted, alpha);
563
564 let title_galley = ui.ctx().fonts_mut(|f| {
568 f.layout(
569 entry.title.clone(),
570 egui::FontId::proportional(t.body),
571 Color32::PLACEHOLDER,
572 text_width,
573 )
574 });
575 let title_size_y = title_galley.size().y;
576 let title_pos = Pos2::new(text_left, rect.min.y + PAD_Y);
577 ui.painter().galley(title_pos, title_galley, title_color);
578
579 if let Some(desc) = &entry.description {
580 let desc_galley = ui.ctx().fonts_mut(|f| {
581 f.layout(
582 desc.clone(),
583 egui::FontId::proportional(t.small),
584 Color32::PLACEHOLDER,
585 text_width,
586 )
587 });
588 let desc_pos = Pos2::new(
589 text_left,
590 rect.min.y + PAD_Y + title_size_y + TITLE_DESC_GAP,
591 );
592 ui.painter().galley(desc_pos, desc_galley, desc_color);
593 }
594
595 close_resp.clicked()
596}
597
598fn paint_clear_all(ui: &mut Ui, theme: &Theme, rect: Rect) -> bool {
603 let p = &theme.palette;
604 let t = &theme.typography;
605
606 ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
607 node.set_role(accesskit::Role::Button);
608 node.set_label("Clear all notifications");
609 });
610
611 let resp = ui.allocate_rect(rect, Sense::click());
612 let painter = ui.painter();
613
614 let bg = if resp.hovered() {
615 p.depth_tint(p.card, 0.10)
616 } else {
617 p.depth_tint(p.card, 0.04)
618 };
619 let radius = CornerRadius::same((rect.height() * 0.5).round() as u8);
620 painter.rect(
621 rect,
622 radius,
623 bg,
624 Stroke::new(1.0, p.border),
625 StrokeKind::Inside,
626 );
627
628 let text_color = if resp.hovered() { p.text } else { p.text_muted };
629 let galley = crate::theme::placeholder_galley(ui, "Clear all", t.small, false, f32::INFINITY);
630 let text_pos = Pos2::new(
631 rect.center().x - galley.size().x * 0.5,
632 rect.center().y - galley.size().y * 0.5,
633 );
634 painter.galley(text_pos, galley, text_color);
635
636 resp.clicked()
637}