forked from reya/gpui-native-kit
349 lines
10 KiB
Rust
349 lines
10 KiB
Rust
//! 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<dyn Fn(&mut App)>;
|
|
|
|
/// 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<L, F>(label: L, handler: F) -> Self
|
|
where
|
|
L: Into<SharedString>,
|
|
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<IconRole>,
|
|
action: Option<ToastAction>,
|
|
duration: Option<Duration>,
|
|
on_dismiss: Option<DismissHandler>,
|
|
}
|
|
|
|
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<L>(message: L) -> Self
|
|
where
|
|
L: Into<SharedString>,
|
|
{
|
|
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<F>(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<ToastEntry>,
|
|
exiting: Option<ToastEntry>,
|
|
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<Self>) -> 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<Self>) {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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<Toasts>,
|
|
id: ElementId,
|
|
}
|
|
|
|
impl ToastsView {
|
|
/// Binds the view to a toast store.
|
|
pub fn new<I>(id: I, toasts: Entity<Toasts>) -> Self
|
|
where
|
|
I: Into<ElementId>,
|
|
{
|
|
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<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
|
|
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);
|
|
});
|
|
}),
|
|
)
|
|
}
|