1#![forbid(unsafe_code)]
2
3use std::cell::{Cell, RefCell};
4use std::collections::HashMap;
5use std::rc::Rc;
6use std::time::{Duration, Instant};
7
8use sequoia_core::{Color, Edges, Length, Vec2};
9use sequoia_layout::{Align, LayoutStyle};
10use sequoia_reactive::Signal;
11use sequoia_style::{Backdrop, Theme, ToastStyle};
12use sequoia_view::{
13 Anchor, AnyView, BackdropBlur, Column, Dismiss, Easing, ElementId, InputMode, OverlayEntry,
14 OverlayId, OverlayLayer, OverlayOverflow, PaintCtx, Placement, Presence, Row, Runtime, Text,
15 Unit, View,
16};
17
18use crate::Container;
19
20pub(crate) const TOAST_ENTER_SECS: f32 = 0.3;
21pub(crate) const TOAST_EXIT_SECS: f32 = 0.25;
22const TOAST_ENTER_EASE: Easing = Easing::CubicBezier(0.3, 1.4, 0.4, 1.0);
23const TOAST_ENTER_DY: f32 = 14.0;
24const TOAST_EXIT_DY: f32 = 8.0;
25
26thread_local! {
27 static ENTRIES: RefCell<HashMap<usize, (OverlayId, u64)>> = RefCell::new(HashMap::new());
28}
29
30fn get_entry(key: usize) -> Option<(OverlayId, u64)> {
31 ENTRIES.with(|m| m.borrow().get(&key).copied())
32}
33
34fn insert_entry(key: usize, id: OverlayId, version: u64) {
35 ENTRIES.with(|m| {
36 m.borrow_mut().insert(key, (id, version));
37 });
38}
39
40fn take_entry(key: usize) -> Option<(OverlayId, u64)> {
41 ENTRIES.with(|m| m.borrow_mut().remove(&key))
42}
43
44pub const DEFAULT_TTL: Duration = Duration::from_secs(4);
45
46pub(crate) const BOTTOM_GAP: f32 = 22.0;
47
48pub(crate) const CARD_WIDTH: f32 = 320.0;
49
50pub const DEFAULT_MAX_VISIBLE: usize = 3;
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum ToastKind {
55 Info,
56 Success,
57 Warn,
58 Error,
59}
60
61impl ToastKind {
62 pub fn fallback_color(self) -> Color {
63 match self {
64 ToastKind::Info => Color::hex(0x2383E2),
65 ToastKind::Success => Color::hex(0x3FB950),
66 ToastKind::Warn => Color::hex(0xD29922),
67 ToastKind::Error => Color::hex(0xF85149),
68 }
69 }
70}
71
72#[derive(Clone, Debug, PartialEq)]
73pub struct Toast {
74 pub id: u64,
75 pub kind: ToastKind,
76 pub title: String,
77 pub message: String,
78 pub ttl: Duration,
79}
80
81#[derive(Clone)]
82pub struct ToastQueue {
83 items: Signal<Vec<Toast>>,
84 deadlines: Rc<RefCell<HashMap<u64, Instant>>>,
85 next: Rc<Cell<u64>>,
86}
87
88impl ToastQueue {
89 pub fn new() -> Self {
90 Self {
91 items: Signal::new(Vec::new()),
92 deadlines: Rc::new(RefCell::new(HashMap::new())),
93 next: Rc::new(Cell::new(1)),
94 }
95 }
96
97 pub fn items(&self) -> Signal<Vec<Toast>> {
98 self.items.clone()
99 }
100
101 pub fn push(
102 &self,
103 kind: ToastKind,
104 title: impl Into<String>,
105 message: impl Into<String>,
106 ttl: Duration,
107 ) -> u64 {
108 let id = self.next.get();
109 self.next.set(id.wrapping_add(1).max(1));
110 let toast = Toast {
111 id,
112 kind,
113 title: title.into(),
114 message: message.into(),
115 ttl,
116 };
117 self.deadlines.borrow_mut().insert(id, Instant::now() + ttl);
118 self.items.update(|v| v.push(toast));
119 id
120 }
121
122 pub fn info(&self, title: impl Into<String>, message: impl Into<String>) -> u64 {
123 self.push(ToastKind::Info, title, message, DEFAULT_TTL)
124 }
125
126 pub fn success(&self, title: impl Into<String>, message: impl Into<String>) -> u64 {
127 self.push(ToastKind::Success, title, message, DEFAULT_TTL)
128 }
129
130 pub fn warn(&self, title: impl Into<String>, message: impl Into<String>) -> u64 {
131 self.push(ToastKind::Warn, title, message, DEFAULT_TTL)
132 }
133
134 pub fn error(&self, title: impl Into<String>, message: impl Into<String>) -> u64 {
135 self.push(ToastKind::Error, title, message, DEFAULT_TTL)
136 }
137
138 pub fn dismiss(&self, id: u64) -> bool {
139 let had = self.items.get_untracked().iter().any(|t| t.id == id);
140 self.deadlines.borrow_mut().remove(&id);
141 self.items.update(|v| v.retain(|t| t.id != id));
142 had
143 }
144
145 pub fn clear(&self) {
146 self.deadlines.borrow_mut().clear();
147 self.items.set(Vec::new());
148 }
149
150 pub fn len(&self) -> usize {
151 self.items.get_untracked().len()
152 }
153
154 pub fn is_empty(&self) -> bool {
155 self.items.get_untracked().is_empty()
156 }
157
158 pub(crate) fn deadline(&self, id: u64) -> Option<Instant> {
159 self.deadlines.borrow().get(&id).copied()
160 }
161}
162
163impl Default for ToastQueue {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169#[derive(Clone)]
170struct ResolvedRow {
171 background: Color,
172 border: Color,
173 border_width: f32,
174 text: Color,
175 text_secondary: Color,
176 font_size: f32,
177 radius: f32,
178 pad_x: f32,
179 pad_y: f32,
180 accent: Color,
181 width: f32,
182 backdrop: Backdrop,
183}
184
185struct ToastRow {
186 toast: Toast,
187 queue: ToastQueue,
188 style: ResolvedRow,
189 content: AnyView,
190
191 fade_out: Rc<RefCell<Option<f32>>>,
192}
193
194impl ToastRow {
195 fn new(toast: Toast, queue: ToastQueue, style: &ResolvedRow) -> Self {
196 let dismiss_queue = queue.clone();
197 let dismiss_id = toast.id;
198 let text_w = (style.width - style.pad_x * 2.0 - 56.0).max(80.0);
199 let mut col = Column::new().gap(2.0).child(
200 Text::new(toast.title.clone())
201 .size(style.font_size)
202 .bold()
203 .color(style.text),
204 );
205 if !toast.message.is_empty() {
206 col = col.child(
207 Text::new(toast.message.clone())
208 .size((style.font_size - 1.0).max(10.0))
209 .color(style.text_secondary)
210 .wrap_width(text_w),
211 );
212 }
213
214 let content = AnyView::new(
215 Presence::new(Signal::new(true))
216 .from(0.0)
217 .enter_duration(TOAST_ENTER_SECS)
218 .enter_easing(TOAST_ENTER_EASE)
219 .slide(0.0, TOAST_ENTER_DY)
220 .child(BackdropBlur::new(
221 Container::new()
222 .width(style.width)
223 .background(style.background)
224 .border(style.border_width, style.border)
225 .radius(style.radius)
226 .padding_xy(style.pad_x, style.pad_y)
227 .child(
228 Row::new()
229 .gap(10.0)
230 .align_items(Align::Center)
231 .child(
232 Container::new()
233 .square(8.0)
234 .radius(4.0)
235 .background(style.accent),
236 )
237 .child(col)
238 .child(
239 Container::new()
240 .padding_xy(4.0, 4.0)
241 .child(
242 Text::new("×").size(14.0).color(style.text_secondary),
243 )
244 .on_click(move || {
245 dismiss_queue.dismiss(dismiss_id);
246 }),
247 ),
248 ),
249 Backdrop {
250 radius: style.radius,
251 ..style.backdrop
252 },
253 )),
254 );
255 Self {
256 toast,
257 queue,
258 style: style.clone(),
259 content,
260 fade_out: Rc::new(RefCell::new(None)),
261 }
262 }
263
264 fn exit_progress(&self, now: f32) -> Option<f32> {
265 let start = (*self.fade_out.borrow())?;
266 let el = (now - start).max(0.0);
267 Some((el / TOAST_EXIT_SECS.max(f32::MIN_POSITIVE)).clamp(0.0, 1.0))
268 }
269}
270
271impl View for ToastRow {
272 fn body(&self) -> impl View + 'static {
273 Unit
274 }
275 fn children_override(&self) -> Option<Vec<AnyView>> {
276 Some(vec![self.content.clone()])
277 }
278 fn layout_style(&self) -> LayoutStyle {
279 LayoutStyle {
280 width: Length::Fit,
281 height: Length::Fit,
282 ..LayoutStyle::default()
283 }
284 }
285 fn paint_opacity_ctx(&self, rt: &Runtime, _el: ElementId) -> Option<f32> {
286 self.exit_progress(rt.elapsed_secs()).map(|p| 1.0 - p)
287 }
288 fn paint_offset_ctx(&self, rt: &Runtime, _el: ElementId) -> Vec2 {
289 match self.exit_progress(rt.elapsed_secs()) {
290 Some(p) => Vec2::new(0.0, TOAST_EXIT_DY * p),
291 None => Vec2::ZERO,
292 }
293 }
294 fn paint_self(&self, ctx: &mut PaintCtx) {
295 let _ = &self.style;
296 let now = ctx.runtime().elapsed_secs();
297 if let Some(deadline) = self.queue.deadline(self.toast.id)
298 && Instant::now() >= deadline
299 {
300 let start = *self.fade_out.borrow_mut().get_or_insert(now);
301 if now - start >= TOAST_EXIT_SECS {
302 self.queue.dismiss(self.toast.id);
303 return;
304 }
305 }
306
307 ctx.request_frame();
308 }
309}
310
311struct ToastColumn {
312 rows: Vec<(u64, AnyView)>,
313}
314
315impl View for ToastColumn {
316 fn body(&self) -> impl View + 'static {
317 Unit
318 }
319 fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
320 Some(self.rows.clone())
321 }
322 fn layout_style(&self) -> LayoutStyle {
323 LayoutStyle {
324 width: Length::Fit,
325 height: Length::Fit,
326 ..LayoutStyle::default()
327 }
328 }
329}
330
331pub struct ToastStack {
332 queue: ToastQueue,
333 style: Option<ToastStyle>,
334 background: Option<Color>,
335 border: Option<Color>,
336 border_width: Option<f32>,
337 text: Option<Color>,
338 text_secondary: Option<Color>,
339 font_size: Option<f32>,
340 radius: Option<f32>,
341 padding: Option<(f32, f32)>,
342 backdrop: Option<Backdrop>,
343 max_visible: usize,
344 width: f32,
345 margin: Edges,
346}
347
348impl ToastStack {
349 pub fn new(queue: ToastQueue) -> Self {
350 Self {
351 queue,
352 style: None,
353 background: None,
354 border: None,
355 border_width: None,
356 text: None,
357 text_secondary: None,
358 font_size: None,
359 radius: None,
360 padding: None,
361 backdrop: None,
362 max_visible: DEFAULT_MAX_VISIBLE,
363 width: CARD_WIDTH,
364 margin: Edges::ZERO,
365 }
366 }
367
368 pub fn style(mut self, style: ToastStyle) -> Self {
369 self.style = Some(style);
370 self
371 }
372
373 pub fn background(mut self, color: Color) -> Self {
374 self.background = Some(color);
375 self
376 }
377
378 pub fn border_color(mut self, color: Color) -> Self {
379 self.border = Some(color);
380 self
381 }
382
383 pub fn border_width(mut self, width: f32) -> Self {
384 self.border_width = Some(width.max(0.0));
385 self
386 }
387
388 pub fn border(mut self, width: f32, color: Color) -> Self {
389 self.border_width = Some(width.max(0.0));
390 self.border = Some(color);
391 self
392 }
393
394 pub fn text_color(mut self, color: Color) -> Self {
395 self.text = Some(color);
396 self
397 }
398
399 pub fn text_secondary(mut self, color: Color) -> Self {
400 self.text_secondary = Some(color);
401 self
402 }
403
404 pub fn font_size(mut self, size: f32) -> Self {
405 self.font_size = Some(size.max(1.0));
406 self
407 }
408
409 pub fn radius(mut self, radius: f32) -> Self {
410 self.radius = Some(radius.max(0.0));
411 self
412 }
413
414 pub fn padding_xy(mut self, x: f32, y: f32) -> Self {
415 self.padding = Some((x.max(0.0), y.max(0.0)));
416 self
417 }
418
419 pub fn backdrop(mut self, backdrop: Backdrop) -> Self {
420 self.backdrop = Some(backdrop);
421 self
422 }
423
424 pub fn max_visible(mut self, n: usize) -> Self {
425 self.max_visible = n.max(1);
426 self
427 }
428
429 pub fn width(mut self, width: f32) -> Self {
430 self.width = width.max(120.0);
431 self
432 }
433
434 pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
435 let m = margin.into();
436 self.margin = Edges::only(
437 sanitize_margin(m.left),
438 sanitize_margin(m.top),
439 sanitize_margin(m.right),
440 sanitize_margin(m.bottom),
441 );
442 self
443 }
444
445 pub fn margin_xy(self, x: f32, y: f32) -> Self {
446 self.margin(Edges::xy(x, y))
447 }
448
449 pub fn margin_x(mut self, x: f32) -> Self {
450 let x = sanitize_margin(x);
451 self.margin.left = x;
452 self.margin.right = x;
453 self
454 }
455
456 pub fn margin_y(mut self, y: f32) -> Self {
457 let y = sanitize_margin(y);
458 self.margin.top = y;
459 self.margin.bottom = y;
460 self
461 }
462
463 pub fn margin_left(mut self, v: f32) -> Self {
464 self.margin.left = sanitize_margin(v);
465 self
466 }
467
468 pub fn margin_right(mut self, v: f32) -> Self {
469 self.margin.right = sanitize_margin(v);
470 self
471 }
472
473 pub fn margin_top(mut self, v: f32) -> Self {
474 self.margin.top = sanitize_margin(v);
475 self
476 }
477
478 pub fn margin_bottom(mut self, v: f32) -> Self {
479 self.margin.bottom = sanitize_margin(v);
480 self
481 }
482
483 pub fn queue(&self) -> ToastQueue {
484 self.queue.clone()
485 }
486
487 fn key(&self) -> usize {
488 self.queue.items.id()
489 }
490
491 fn accent(&self, kind: ToastKind, style: ToastStyle) -> Color {
492 let slot = match kind {
493 ToastKind::Info => style.info,
494 ToastKind::Success => style.success,
495 ToastKind::Warn => style.warn,
496 ToastKind::Error => style.error,
497 };
498 slot.unwrap_or_else(|| kind.fallback_color())
499 }
500
501 fn sync_overlay(&self, ctx: &mut PaintCtx, theme: &Theme) {
502 let key = self.key();
503 let items = self.queue.items.get_untracked();
504 if items.is_empty() {
505 if let Some((id, _)) = take_entry(key) {
506 ctx.hide_overlay(id);
507 }
508 return;
509 }
510 let version = self.queue.items.version();
511 if let Some((id, shown)) = get_entry(key) {
512 if !ctx.has_overlay(id) {
513 take_entry(key);
514 } else if shown == version {
515 return;
516 } else {
517 ctx.hide_overlay(id);
518 take_entry(key);
519 }
520 }
521 let style = self.style.unwrap_or_default();
522 let (pad_x, pad_y) = self.padding.or(style.padding).unwrap_or((12.0, 10.0));
523 let font_size = self
524 .font_size
525 .or(style.font_size)
526 .unwrap_or(theme.font_size);
527 let backdrop = self.backdrop.or(style.backdrop).unwrap_or(Backdrop::XS);
528 let max = self.max_visible;
529 let mut rows: Vec<(u64, AnyView)> = Vec::new();
530 for toast in items.iter().take(max) {
531 let rs = ResolvedRow {
532 background: self
533 .background
534 .or(style.background)
535 .unwrap_or(theme.surface),
536 border: self.border.or(style.border).unwrap_or(theme.border),
537 border_width: self
538 .border_width
539 .or(style.border_width)
540 .unwrap_or(1.0)
541 .max(0.0),
542 text: self.text.or(style.text).unwrap_or(theme.text),
543 text_secondary: self
544 .text_secondary
545 .or(style.text_secondary)
546 .unwrap_or(theme.text_secondary),
547 font_size,
548 radius: self.radius.or(style.radius).unwrap_or(4.0),
549 pad_x,
550 pad_y,
551 accent: self.accent(toast.kind, style),
552 width: self.width,
553 backdrop,
554 };
555 rows.push((
556 toast.id,
557 AnyView::new(ToastRow::new(toast.clone(), self.queue.clone(), &rs)),
558 ));
559 }
560 if items.len() > max {
561 let more = items.len() - max;
562 rows.push((
563 u64::MAX,
564 AnyView::new(
565 Container::new()
566 .background(Color::TRANSPARENT)
567 .padding_xy(4.0, 2.0)
568 .child(
569 Text::new(format!("+{more} more"))
570 .size((font_size - 1.0).max(10.0))
571 .color(theme.text_secondary),
572 ),
573 ),
574 ));
575 }
576 let id = ctx.show_overlay(
577 OverlayEntry::new(ToastColumn { rows })
578 .anchor(Anchor::Viewport)
579 .placement(Placement::Bottom)
580 .layer(OverlayLayer::System)
581 .input_mode(InputMode::Interactive)
582 .dismiss(Dismiss::Manual)
583 .overflow(OverlayOverflow::Clamp)
584 .offset(0.0, -BOTTOM_GAP),
585 );
586 insert_entry(key, id, version);
587 }
588}
589
590impl View for ToastStack {
591 fn body(&self) -> impl View + 'static {
592 Unit
593 }
594 fn is_leaf(&self) -> bool {
595 true
596 }
597
598 fn hit_testable(&self) -> bool {
599 false
600 }
601 fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
602 let _ = self.queue.items.get();
603 Some(Vec::new())
604 }
605 fn layout_style(&self) -> LayoutStyle {
606 LayoutStyle {
607 width: Length::Fit,
608 height: Length::Fit,
609 margin: self.margin,
610 ..LayoutStyle::default()
611 }
612 }
613 fn on_unmount(&self) {
614 take_entry(self.key());
615 }
616 fn paint_self(&self, ctx: &mut PaintCtx) {
617 let theme = ctx.theme();
618 self.sync_overlay(ctx, &theme);
619 }
620}
621
622fn sanitize_margin(v: f32) -> f32 {
623 if v.is_finite() { v.max(0.0) } else { 0.0 }
624}