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