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}