Skip to main content

sequoia_elements/
toast.rs

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}