//! Toasts: succinct, temporary messages at the bottom center of the window //! — Adwaita Toast, the macOS banner. One toast at a time: content, an //! optional action, and a close button; fades in, then fades out. use std::rc::Rc; use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, Window, deferred, div, ease_out_quint, px, }; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::ActiveTheme as _; use crate::common::DismissHandler; /// The action handler of a [`Toast`]. pub type ActionHandler = Rc; /// One user action offered by a toast. #[derive(Clone)] pub struct ToastAction { label: SharedString, handler: ActionHandler, } impl ToastAction { /// Creates an action with this label. pub fn new(label: L, handler: F) -> Self where L: Into, F: Fn(&mut App) + 'static, { Self { label: label.into(), handler: Rc::new(handler), } } } /// A toast notification. #[derive(Clone)] pub struct Toast { message: SharedString, icon: Option, action: Option, duration: Option, on_dismiss: Option, } impl Toast { /// How long a toast stays up by default. pub const DURATION: Duration = Duration::from_secs(5); /// Creates a toast with this one-line content. pub fn new(message: L) -> Self where L: Into, { Self { message: message.into(), icon: None, action: None, duration: Some(Self::DURATION), on_dismiss: None, } } /// Shows this glyph before the content. pub fn icon(mut self, role: IconRole) -> Self { self.icon = Some(role); self } /// Offers the user this action. pub fn action(mut self, action: ToastAction) -> Self { self.action = Some(action); self } /// Sets the time before the toast dismisses itself. pub fn duration(mut self, duration: Duration) -> Self { self.duration = Some(duration); self } /// Runs when the toast is dismissed by any means, once it is fully gone. pub fn on_dismiss(mut self, handler: F) -> Self where F: Fn(&mut App) + 'static, { self.on_dismiss = Some(Rc::new(handler)); self } } /// One raised toast, with its identity in the store. #[derive(Clone)] pub struct ToastEntry { pub id: usize, pub toast: Toast, } /// The store of raised toasts. #[derive(Default)] pub struct Toasts { current: Option, exiting: Option, next_id: usize, } impl Toasts { /// Raises a toast, replacing the one on screen. Returns its id. pub fn push(&mut self, toast: Toast, cx: &mut Context) -> usize { let id = self.next_id; self.next_id += 1; let duration = toast.duration; if let Some(previous) = self.current.take() { self.start_exit(previous, cx); } self.current = Some(ToastEntry { id, toast }); cx.notify(); if let Some(duration) = duration.filter(|duration| !duration.is_zero()) { cx.spawn(async move |this, cx| { cx.background_executor().timer(duration).await; this.update(cx, |toasts, cx| toasts.dismiss(id, cx)).ok(); }) .detach(); } id } /// Dismisses one toast by id. pub fn dismiss(&mut self, id: usize, cx: &mut Context) { if self.exiting.as_ref().is_some_and(|entry| entry.id == id) { self.finish_exit(cx); return; } if self.current.as_ref().is_some_and(|entry| entry.id == id) { let entry = self.current.take().expect("current checked above"); self.start_exit(entry, cx); } } /// Dismisses the toast on screen. pub fn clear(&mut self, cx: &mut Context) { if let Some(entry) = self.current.take() { self.start_exit(entry, cx); } else { self.finish_exit(cx); } } /// The toast now showing, if any. pub fn current(&self) -> Option<&ToastEntry> { self.current.as_ref() } /// How many toasts are on screen, including one fading out. pub fn count(&self) -> usize { usize::from(self.current.is_some()) + usize::from(self.exiting.is_some()) } /// Moves a toast into the fade-out phase. fn start_exit(&mut self, entry: ToastEntry, cx: &mut Context) { if let Some(previous) = self.exiting.take() && let Some(on_dismiss) = &previous.toast.on_dismiss { (on_dismiss.clone())(cx); } self.exiting = Some(entry); cx.notify(); let fade = cx.theme().motion.overlay; cx.spawn(async move |this, cx| { cx.background_executor().timer(fade).await; this.update(cx, |toasts, cx| toasts.finish_exit(cx)).ok(); }) .detach(); } fn finish_exit(&mut self, cx: &mut Context) { if let Some(entry) = self.exiting.take() { cx.notify(); if let Some(on_dismiss) = &entry.toast.on_dismiss { (on_dismiss.clone())(cx); } } } } #[derive(IntoElement)] pub struct ToastsView { toasts: Entity, id: ElementId, } impl ToastsView { /// Binds the view to a toast store. pub fn new(id: I, toasts: Entity) -> Self where I: Into, { Self { id: id.into(), toasts, } } } impl RenderOnce for ToastsView { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let fade = cx.theme().motion.overlay; let exiting = self.toasts.read(cx).exiting.clone(); let current = self.toasts.read(cx).current.clone(); deferred( div() .id(self.id) .absolute() .left_0() .bottom_4() .w_full() .flex() .flex_col() .items_center() .when_some(exiting, |column, entry| { let id = entry.id; column.child(pill(&entry, &self.toasts, cx).with_animation( ("toast-exit", id), Animation::new(fade), |pill, delta| pill.opacity(1. - delta), )) }) .when_some(current, |column, entry| { let id = entry.id; column.child(pill(&entry, &self.toasts, cx).with_animation( ("toast-enter", id), Animation::new(fade).with_easing(ease_out_quint()), |pill, delta| pill.opacity(delta), )) }), ) } } fn pill(entry: &ToastEntry, toasts: &Entity, cx: &App) -> gpui::Stateful { let theme = cx.theme(); let surface = theme.colors.surface; let border = theme.colors.border; let foreground = theme.colors.foreground; let muted = theme.colors.muted; let primary = theme.ramps.primary.base; let primary_hover = theme.ramps.primary.hover; let primary_foreground = theme.colors.primary_foreground; let radius = theme.radius.full; let shadows = theme.elevations.popover.shadows(); let icon_size = theme.sizes.small.icon_size; let text_size = theme.sizes.small.text_size; let toast = &entry.toast; let toasts = toasts.clone(); let id = entry.id; div() .id(("toast", id)) .max_w(px(480.)) .flex() .items_center() .py_1p5() .px_3() .bg(surface) .border_1() .border_color(border) .rounded(radius) .shadow(shadows.clone()) .when_some(toast.icon, |pill, icon_role| { pill.child( div() .flex_none() .mr_2() .flex() .items_center() .child(icon(icon_role).size(icon_size).color(foreground)), ) }) .child( div() .min_w(px(0.)) .truncate() .text_size(text_size) .text_color(foreground) .child(toast.message.clone()), ) .when_some(toast.action.clone(), |pill, action| { let toasts = toasts.clone(); pill.child( div() .id(("toast-action", id)) .flex_none() .ml_1p5() .cursor_pointer() .px_2() .py_0p5() .rounded(radius) .bg(primary) .hover(|style| style.bg(primary_hover)) .text_size(text_size) .text_color(primary_foreground) .child(action.label.clone()) .on_click(move |_, _window, cx| { (action.handler.clone())(cx); toasts.update(cx, |toasts, cx| { toasts.dismiss(id, cx); }); }), ) }) .child( div() .id(("toast-close", id)) .flex_none() .ml_4() .cursor_pointer() .p_0p5() .rounded(px(4.)) .hover(|style| style.bg(muted)) .child( icon(IconRole::Close) .size(icon_size * 0.75) .color(foreground.alpha(0.55)), ) .on_click(move |_, _window, cx| { toasts.update(cx, |toasts, cx| { toasts.dismiss(id, cx); }); }), ) }