1use std::rc::Rc;
24use std::time::Duration;
25
26use gpui::{
27 AnyElement, App, Context, Entity, Global, InteractiveElement, IntoElement, ParentElement,
28 Render, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, div,
29 prelude::FluentBuilder, px,
30};
31use gpui_kit_assets::{Icon, icon};
32use gpui_kit_semantics::{LiveRegion, NodeSpec, Role, Semantic};
33use gpui_kit_theme::{ActiveTheme, Elevation, Layer, Space, Theme, TypeScale};
34use web_time::Instant;
35
36use crate::controls::button::Button;
37use crate::display::badge::Tone;
38use crate::display::status::StatusDot;
39use crate::foundation::{FocusRing, Ident, Sizable, StyledExt};
40use crate::motion::{Easing, Flipping, MotionSpec, Phase, Presence, flip};
41use crate::overlay::layer::{pinned, priority, surface};
42use crate::strings::{ActiveStrings, StringKey};
43
44const DEFAULT_CAPACITY: usize = 3;
46
47const CARD_WIDTH: f32 = 320.0;
51const TRAVEL: f32 = 8.0;
52
53type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum ToastCorner {
58 TopLeft,
59 TopRight,
60 BottomLeft,
61 #[default]
62 BottomRight,
63}
64
65impl ToastCorner {
66 fn is_top(self) -> bool {
67 matches!(self, Self::TopLeft | Self::TopRight)
68 }
69
70 fn is_leading(self) -> bool {
71 matches!(self, Self::TopLeft | Self::BottomLeft)
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum Lifetime {
78 ByTone,
80 Timed(Duration),
81 Persistent,
82}
83
84struct Action {
85 label: SharedString,
86 handler: ActionHandler,
87}
88
89pub struct Toast {
92 ident: Ident,
93 message: SharedString,
94 detail: Option<SharedString>,
95 tone: Tone,
96 action: Option<Action>,
97 dismissable: bool,
98 lifetime: Lifetime,
99}
100
101impl std::fmt::Debug for Toast {
102 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 formatter
104 .debug_struct("Toast")
105 .field("ident", &self.ident)
106 .field("message", &self.message)
107 .field("detail", &self.detail)
108 .field("tone", &self.tone)
109 .field("has_action", &self.action.is_some())
110 .field("dismissable", &self.dismissable)
111 .field("lifetime", &self.lifetime)
112 .finish()
113 }
114}
115
116impl Toast {
117 pub fn new(ident: impl Into<Ident>, message: impl Into<SharedString>) -> Self {
118 Self {
119 ident: ident.into(),
120 message: message.into(),
121 detail: None,
122 tone: Tone::default(),
123 action: None,
124 dismissable: true,
125 lifetime: Lifetime::ByTone,
126 }
127 }
128
129 pub fn tone(mut self, tone: Tone) -> Self {
130 self.tone = tone;
131 self
132 }
133
134 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
136 self.detail = Some(detail.into());
137 self
138 }
139
140 pub fn action(
143 mut self,
144 label: impl Into<SharedString>,
145 handler: impl Fn(&mut Window, &mut App) + 'static,
146 ) -> Self {
147 self.action = Some(Action {
148 label: label.into(),
149 handler: Rc::new(handler),
150 });
151 self
152 }
153
154 pub fn dismissable(mut self, dismissable: bool) -> Self {
157 self.dismissable = dismissable;
158 self
159 }
160
161 pub fn timeout(mut self, timeout: Duration) -> Self {
163 self.lifetime = Lifetime::Timed(timeout);
164 self
165 }
166
167 pub fn persistent(mut self) -> Self {
169 self.lifetime = Lifetime::Persistent;
170 self
171 }
172
173 pub fn ident(&self) -> &Ident {
174 &self.ident
175 }
176
177 fn allowance(&self, theme: &Theme) -> Option<Duration> {
182 match self.lifetime {
183 Lifetime::Timed(timeout) => Some(timeout),
184 Lifetime::Persistent => None,
185 Lifetime::ByTone if reports_failure(self.tone) => None,
186 Lifetime::ByTone => Some(Duration::from_millis(theme.motion.toast_ms)),
187 }
188 }
189}
190
191fn reports_failure(tone: Tone) -> bool {
192 matches!(tone, Tone::Danger | Tone::Warning)
193}
194
195fn enter_spec(theme: &Theme) -> MotionSpec {
196 MotionSpec::new(theme.motion.menu_ms, Easing::Settle.curve(theme))
197}
198
199fn exit_spec(theme: &Theme) -> MotionSpec {
200 MotionSpec::new(theme.motion.quick_ms, Easing::Exit.curve(theme))
201}
202
203struct Entry {
205 toast: Toast,
206 presence: Presence,
207 remaining: Option<Duration>,
209 hovered: bool,
210 progress: f32,
211}
212
213impl Entry {
214 fn new(toast: Toast, remaining: Option<Duration>, theme: &Theme) -> Self {
215 let mut presence = Presence::hidden(enter_spec(theme), exit_spec(theme));
216 presence.show();
217 Self {
218 toast,
219 presence,
220 remaining,
221 hovered: false,
222 progress: 0.0,
223 }
224 }
225
226 fn id(&self) -> SharedString {
227 self.toast.ident.semantic_id()
228 }
229
230 fn leaving(&self) -> bool {
231 matches!(self.presence.phase(), Phase::Exiting | Phase::Gone)
232 }
233
234 fn evictable(&self) -> bool {
237 !self.leaving() && self.toast.dismissable && self.remaining.is_some()
238 }
239
240 fn count_down(&mut self, delta: Duration) -> bool {
243 if self.hovered || !matches!(self.presence.phase(), Phase::Entering | Phase::Present) {
244 return false;
245 }
246 let Some(remaining) = self.remaining else {
247 return false;
248 };
249 let left = remaining.saturating_sub(delta);
250 self.remaining = Some(left);
251 if left.is_zero() {
252 self.presence.hide();
253 return false;
254 }
255 true
256 }
257}
258
259pub struct ToastLayer {
264 corner: ToastCorner,
265 capacity: usize,
266 entries: Vec<Entry>,
267 last_tick: Option<Instant>,
269}
270
271impl std::fmt::Debug for ToastLayer {
272 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 formatter
274 .debug_struct("ToastLayer")
275 .field("corner", &self.corner)
276 .field("capacity", &self.capacity)
277 .field("toasts", &self.entries.len())
278 .finish()
279 }
280}
281
282impl ToastLayer {
283 pub fn new(cx: &mut Context<Self>) -> Self {
288 let handle = cx.weak_entity();
289 cx.set_global(ToastCenter {
290 layer: Some(handle),
291 });
292 Self {
293 corner: ToastCorner::default(),
294 capacity: DEFAULT_CAPACITY,
295 entries: Vec::new(),
296 last_tick: None,
297 }
298 }
299
300 pub fn corner(mut self, corner: ToastCorner) -> Self {
301 self.corner = corner;
302 self
303 }
304
305 pub fn capacity(mut self, capacity: usize) -> Self {
307 self.capacity = capacity.max(1);
308 self
309 }
310
311 pub fn len(&self) -> usize {
312 self.entries.len()
313 }
314
315 pub fn is_empty(&self) -> bool {
316 self.entries.is_empty()
317 }
318
319 pub fn phase(&self, ident: &str) -> Option<Phase> {
321 self.entry(ident).map(|entry| entry.presence.phase())
322 }
323
324 pub fn push(&mut self, toast: Toast, cx: &mut Context<Self>) {
326 let theme = cx.theme().clone();
327 let remaining = toast.allowance(&theme);
328 let id = toast.ident.semantic_id();
329
330 if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id() == id) {
331 entry.toast = toast;
334 entry.remaining = remaining;
335 entry.hovered = false;
336 entry.presence.show();
337 cx.notify();
338 return;
339 }
340
341 self.make_room();
342 self.entries.push(Entry::new(toast, remaining, &theme));
343 cx.notify();
344 }
345
346 pub fn dismiss(&mut self, ident: &str, cx: &mut Context<Self>) -> bool {
349 let Some(entry) = self
350 .entries
351 .iter_mut()
352 .find(|entry| entry.id() == ident && !entry.leaving())
353 else {
354 return false;
355 };
356 entry.presence.hide();
357 cx.notify();
358 true
359 }
360
361 pub fn clear(&mut self, cx: &mut Context<Self>) {
362 for entry in &mut self.entries {
363 entry.presence.hide();
364 }
365 cx.notify();
366 }
367
368 fn entry(&self, ident: &str) -> Option<&Entry> {
369 self.entries.iter().find(|entry| entry.id() == ident)
370 }
371
372 fn set_hovered(&mut self, ident: &SharedString, hovered: bool, cx: &mut Context<Self>) {
373 if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id() == *ident) {
374 entry.hovered = hovered;
375 cx.notify();
376 }
377 }
378
379 fn make_room(&mut self) {
383 while self.standing() >= self.capacity {
384 let Some(index) = self.entries.iter().position(Entry::evictable) else {
385 break;
386 };
387 self.entries[index].presence.hide();
388 }
389 }
390
391 fn standing(&self) -> usize {
392 self.entries.iter().filter(|entry| !entry.leaving()).count()
393 }
394
395 fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
397 let now = cx.background_executor().now();
398 let delta = self
399 .last_tick
400 .map(|last| now.saturating_duration_since(last))
401 .unwrap_or_default();
402
403 let mut timing = false;
404 for entry in &mut self.entries {
405 timing |= entry.count_down(delta);
406 }
407 for entry in &mut self.entries {
408 entry.progress = entry.presence.animate(window, cx);
409 }
410 self.entries.retain(|entry| entry.presence.is_rendered());
411
412 self.last_tick = timing.then_some(now);
413 if timing {
414 window.request_animation_frame();
415 }
416 }
417
418 fn card(
419 &self,
420 index: usize,
421 theme: &Theme,
422 window: &mut Window,
423 cx: &mut Context<Self>,
424 ) -> AnyElement {
425 let entry = &self.entries[index];
426 let toast = &entry.toast;
427 let id = toast.ident.semantic_id();
428 let color = toast.tone.color(theme);
429 let layer = cx.entity().downgrade();
430
431 let action = toast.action.as_ref().map(|action| {
432 let handler = Rc::clone(&action.handler);
433 let layer = layer.clone();
434 let id = id.clone();
435 Button::new(toast.ident.child("action"))
436 .semantic_parent(id.clone())
437 .label(action.label.clone())
438 .secondary()
439 .small()
440 .on_click(move |window, cx| {
441 handler(window, cx);
442 layer
443 .update(cx, |layer, cx| layer.dismiss(id.as_ref(), cx))
444 .ok();
445 })
446 });
447
448 let dismiss = toast.dismissable.then(|| {
449 let dismiss_ident = toast.ident.child("dismiss");
450 let mut control = div()
451 .id(dismiss_ident.element_id())
452 .flex()
453 .flex_none()
454 .items_center()
455 .justify_center()
456 .size(px(16.0))
457 .cursor_pointer()
458 .tab_index(0)
459 .focus_ring(theme)
460 .child(
461 icon(Icon::Close)
462 .size(px(11.0))
463 .text_color(theme.colors.text_faint),
464 )
465 .semantic_in(
466 cx,
467 NodeSpec::new(dismiss_ident.semantic_id(), Role::Button)
468 .parent(id.clone())
469 .text(cx.strings().text(StringKey::Dismiss)),
470 );
471 let clicked = {
472 let layer = layer.clone();
473 let id = id.clone();
474 move |_window: &mut Window, cx: &mut App| {
475 layer
476 .update(cx, |layer, cx| layer.dismiss(id.as_ref(), cx))
477 .ok();
478 }
479 };
480 let keyed = clicked.clone();
481 control
482 .interactivity()
483 .on_click(move |_, window, cx| clicked(window, cx));
484 control
485 .interactivity()
486 .on_key_down(move |event, window, cx| {
487 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
488 keyed(window, cx);
489 cx.stop_propagation();
490 }
491 });
492 control
493 });
494
495 let detail = toast.detail.clone().map(|detail| {
496 div()
497 .type_scale(theme, TypeScale::Caption)
498 .text_color(theme.colors.text_muted)
499 .child(detail.clone())
500 .semantic_in(
501 cx,
502 NodeSpec::new(toast.ident.child("detail").semantic_id(), Role::Text)
503 .parent(id.clone())
504 .text(detail),
505 )
506 });
507
508 let travel = TRAVEL * (1.0 - entry.progress);
509 let hovered = entry.hovered;
510 let hover_id = id.clone();
511
512 let card = surface(theme, Elevation::Overlay)
513 .id(toast.ident.element_id())
514 .row()
515 .items_start()
516 .w(px(CARD_WIDTH))
517 .gap_token(theme, Space::Sm)
518 .p_token(theme, Space::Md)
519 .bg(color.opacity(0.12))
523 .opacity(entry.progress)
524 .top(px(if self.corner.is_top() {
525 -travel
526 } else {
527 travel
528 }))
529 .on_hover(cx.listener(move |layer, hovered: &bool, _window, cx| {
530 layer.set_hovered(&hover_id, *hovered, cx);
531 }))
532 .child(
533 div()
534 .flex_none()
535 .mt(px(5.0))
536 .child(StatusDot::new(toast.tone)),
537 )
538 .child(
539 div()
540 .column()
541 .flex_1()
542 .min_w_0()
543 .gap_token(theme, Space::Xs)
544 .child(
545 div()
546 .type_scale(theme, TypeScale::Label)
547 .child(toast.message.clone()),
548 )
549 .children(detail),
550 )
551 .children(action)
552 .children(dismiss)
553 .semantic_in(
554 cx,
555 NodeSpec::new(id, Role::Toast)
556 .text(toast.message.clone())
557 .value(toast.tone.name())
558 .live(if toast.tone == Tone::Danger {
559 LiveRegion::Assertive
560 } else {
561 LiveRegion::Polite
562 })
563 .live_atomic(true)
564 .hovered(hovered),
565 );
566
567 let slot = flip(toast.ident.child("slot").semantic_id(), cx);
573 div().child(card).flip(&slot, window, cx).into_any_element()
574 }
575}
576
577impl Render for ToastLayer {
578 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
579 let theme = cx.theme().clone();
580 self.tick(window, cx);
581 if self.entries.is_empty() {
582 return div().into_any_element();
583 }
584
585 let mut order: Vec<usize> = (0..self.entries.len()).collect();
588 if self.corner.is_top() {
589 order.reverse();
590 }
591 let mut cards: Vec<AnyElement> = Vec::with_capacity(order.len());
592 for index in order {
593 cards.push(self.card(index, &theme, window, cx));
594 }
595
596 let leading = self.corner.is_leading();
597 let top = self.corner.is_top();
598 let viewport = window.viewport_size();
599 let stack = div()
600 .column()
601 .gap_token(&theme, Space::Sm)
602 .when(leading, |element| element.items_start())
603 .when(!leading, |element| element.items_end())
604 .children(cards);
605 let frame = div()
608 .absolute()
609 .top_0()
610 .left_0()
611 .w(viewport.width)
612 .h(viewport.height)
613 .flex()
614 .flex_col()
615 .p_token(&theme, Space::Lg)
616 .when(top, |element| element.justify_start())
617 .when(!top, |element| element.justify_end())
618 .when(leading, |element| element.items_start())
619 .when(!leading, |element| element.items_end())
620 .child(stack);
621
622 pinned(
623 gpui::deferred(frame)
624 .priority(priority(&theme, Layer::Toast))
625 .into_any_element(),
626 )
627 }
628}
629
630#[derive(Default)]
636struct ToastCenter {
637 layer: Option<WeakEntity<ToastLayer>>,
638}
639
640impl Global for ToastCenter {}
641
642pub fn install(cx: &mut App) {
645 if !cx.has_global::<ToastCenter>() {
646 cx.set_global(ToastCenter::default());
647 }
648}
649
650fn mounted(cx: &App) -> Option<Entity<ToastLayer>> {
651 cx.try_global::<ToastCenter>()?.layer.as_ref()?.upgrade()
652}
653
654pub fn is_mounted(cx: &App) -> bool {
656 mounted(cx).is_some()
657}
658
659pub fn push(cx: &mut App, toast: Toast) -> bool {
665 let Some(layer) = mounted(cx) else {
666 return false;
667 };
668 layer.update(cx, |layer, cx| layer.push(toast, cx));
669 true
670}
671
672pub fn dismiss(cx: &mut App, ident: &str) -> bool {
674 let Some(layer) = mounted(cx) else {
675 return false;
676 };
677 layer.update(cx, |layer, cx| layer.dismiss(ident, cx))
678}
679
680pub fn clear(cx: &mut App) {
682 if let Some(layer) = mounted(cx) {
683 layer.update(cx, |layer, cx| layer.clear(cx));
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 fn theme() -> Theme {
692 Theme::studio_dark()
693 }
694
695 #[test]
696 fn a_failure_stays_and_everything_else_times_out() {
697 let theme = theme();
698 for tone in [Tone::Danger, Tone::Warning] {
699 assert_eq!(
700 Toast::new("run.failed", "Refused")
701 .tone(tone)
702 .allowance(&theme),
703 None
704 );
705 }
706 for tone in [Tone::Success, Tone::Info, Tone::Neutral, Tone::Accent] {
707 assert_eq!(
708 Toast::new("run.saved", "Saved")
709 .tone(tone)
710 .allowance(&theme),
711 Some(Duration::from_millis(theme.motion.toast_ms))
712 );
713 }
714 }
715
716 #[test]
717 fn an_explicit_lifetime_outranks_the_tone() {
718 let theme = theme();
719 assert_eq!(
720 Toast::new("run.failed", "Refused")
721 .tone(Tone::Danger)
722 .timeout(Duration::from_millis(200))
723 .allowance(&theme),
724 Some(Duration::from_millis(200))
725 );
726 assert_eq!(
727 Toast::new("run.saved", "Saved")
728 .tone(Tone::Success)
729 .persistent()
730 .allowance(&theme),
731 None
732 );
733 }
734
735 #[test]
736 fn only_a_dismissable_toast_that_times_out_can_be_evicted() {
737 let theme = theme();
738 let entry = |toast: Toast| {
739 let remaining = toast.allowance(&theme);
740 Entry::new(toast, remaining, &theme)
741 };
742 assert!(entry(Toast::new("a", "Saved").tone(Tone::Success)).evictable());
743 assert!(!entry(Toast::new("b", "Refused").tone(Tone::Danger)).evictable());
744 assert!(
745 !entry(
746 Toast::new("c", "Saved")
747 .tone(Tone::Success)
748 .dismissable(false)
749 )
750 .evictable()
751 );
752 }
753
754 #[test]
755 fn a_hovered_toast_spends_none_of_its_allowance() {
756 let theme = theme();
757 let mut entry = Entry::new(
758 Toast::new("a", "Saved").timeout(Duration::from_millis(300)),
759 Some(Duration::from_millis(300)),
760 &theme,
761 );
762 entry.hovered = true;
763 assert!(!entry.count_down(Duration::from_millis(500)));
764 assert_eq!(entry.remaining, Some(Duration::from_millis(300)));
765
766 entry.hovered = false;
767 assert!(entry.count_down(Duration::from_millis(100)));
768 assert_eq!(entry.remaining, Some(Duration::from_millis(200)));
769 }
770
771 #[test]
772 fn a_spent_allowance_starts_the_exit_rather_than_dropping_the_toast() {
773 let theme = theme();
774 let mut entry = Entry::new(
775 Toast::new("a", "Saved"),
776 Some(Duration::from_millis(100)),
777 &theme,
778 );
779 entry.presence.settle();
780 assert!(!entry.count_down(Duration::from_millis(100)));
781 assert_eq!(entry.presence.phase(), Phase::Exiting);
782 assert!(entry.presence.is_rendered());
783 }
784
785 #[test]
786 fn the_stack_grows_from_the_corner_it_is_given() {
787 assert!(!ToastCorner::default().is_top());
788 assert!(!ToastCorner::default().is_leading());
789 assert!(ToastCorner::TopLeft.is_top() && ToastCorner::TopLeft.is_leading());
790 }
791}