303 lines
9.6 KiB
Rust
303 lines
9.6 KiB
Rust
use std::rc::Rc;
|
|
use std::time::Duration;
|
|
|
|
use gpui::prelude::FluentBuilder as _;
|
|
use gpui::{
|
|
App, Context, ElementId, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
|
|
RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, Window, deferred, div,
|
|
px,
|
|
};
|
|
use gpui_native_icons::{IconRole, icon};
|
|
use gpui_native_theme::ActiveTheme as _;
|
|
|
|
use crate::common::v_flex;
|
|
|
|
/// The dismiss handler of a [`Toast`].
|
|
pub type DismissHandler = Rc<dyn Fn(&mut App)>;
|
|
|
|
/// How alarming a [`Toast`] is; picks the icon and the accent color.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum ToastSeverity {
|
|
/// The default; uses the primary accent.
|
|
#[default]
|
|
Info,
|
|
/// Uses the success role.
|
|
Success,
|
|
/// Uses the warning role.
|
|
Warning,
|
|
/// Uses the destructive role.
|
|
Danger,
|
|
}
|
|
|
|
/// Toast notification.
|
|
#[derive(Clone)]
|
|
pub struct Toast {
|
|
title: SharedString,
|
|
message: Option<SharedString>,
|
|
severity: ToastSeverity,
|
|
/// The painted glyph; `None` derives it from the severity.
|
|
icon: Option<IconRole>,
|
|
show_icon: bool,
|
|
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 an informational toast with this title.
|
|
pub fn new<L>(title: L) -> Self
|
|
where
|
|
L: Into<SharedString>,
|
|
{
|
|
Self {
|
|
title: title.into(),
|
|
message: None,
|
|
severity: ToastSeverity::Info,
|
|
icon: None,
|
|
show_icon: true,
|
|
duration: Some(Self::DURATION),
|
|
on_dismiss: None,
|
|
}
|
|
}
|
|
|
|
/// Sets a secondary line under the title.
|
|
pub fn message<L>(mut self, message: L) -> Self
|
|
where
|
|
L: Into<SharedString>,
|
|
{
|
|
self.message = Some(message.into());
|
|
self
|
|
}
|
|
|
|
/// Sets how alarming the toast is.
|
|
pub fn severity(mut self, severity: ToastSeverity) -> Self {
|
|
self.severity = severity;
|
|
self
|
|
}
|
|
|
|
/// Overrides the severity-derived icon with this glyph.
|
|
pub fn icon(mut self, role: IconRole) -> Self {
|
|
self.icon = Some(role);
|
|
self
|
|
}
|
|
|
|
/// Hides the icon entirely.
|
|
pub fn no_icon(mut self) -> Self {
|
|
self.show_icon = false;
|
|
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.
|
|
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 {
|
|
entries: Vec<ToastEntry>,
|
|
next_id: usize,
|
|
}
|
|
|
|
impl Toasts {
|
|
/// Raises a toast, starting its auto-dismiss timer. 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;
|
|
|
|
self.entries.push(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, running its dismiss handler.
|
|
pub fn dismiss(&mut self, id: usize, cx: &mut Context<Self>) {
|
|
if let Some(position) = self.entries.iter().position(|entry| entry.id == id) {
|
|
let entry = self.entries.remove(position);
|
|
cx.notify();
|
|
if let Some(on_dismiss) = &entry.toast.on_dismiss {
|
|
on_dismiss(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dismisses every toast.
|
|
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
|
if !self.entries.is_empty() {
|
|
self.entries.clear();
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
/// The raised toasts, oldest first.
|
|
pub fn entries(&self) -> &[ToastEntry] {
|
|
&self.entries
|
|
}
|
|
|
|
/// How many toasts are raised.
|
|
pub fn count(&self) -> usize {
|
|
self.entries.len()
|
|
}
|
|
}
|
|
|
|
#[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 theme = cx.theme();
|
|
let surface = theme.colors.surface;
|
|
let border = theme.colors.border;
|
|
let foreground = theme.colors.foreground;
|
|
let muted_foreground = theme.colors.muted_foreground;
|
|
let muted = theme.colors.muted;
|
|
let radius = theme.radius.overlay;
|
|
let shadows = theme.elevations.popover.shadows();
|
|
let icon_size = theme.sizes.small.icon_size;
|
|
|
|
let entries: Vec<ToastEntry> = self.toasts.read(cx).entries().to_vec();
|
|
|
|
deferred(
|
|
div()
|
|
.id(self.id)
|
|
.absolute()
|
|
.top_4()
|
|
.right_4()
|
|
.flex()
|
|
.flex_col()
|
|
.items_end()
|
|
.gap_2()
|
|
.w_80()
|
|
.children(entries.into_iter().map(|entry| {
|
|
let toast = &entry.toast;
|
|
let toasts = self.toasts.clone();
|
|
|
|
let accent = match toast.severity {
|
|
ToastSeverity::Info => theme.ramps.primary.base,
|
|
ToastSeverity::Success => theme.ramps.success.base,
|
|
ToastSeverity::Warning => theme.ramps.warning.base,
|
|
ToastSeverity::Danger => theme.ramps.destructive.base,
|
|
};
|
|
|
|
let icon_role = toast.icon.unwrap_or(match toast.severity {
|
|
ToastSeverity::Info => IconRole::Info,
|
|
ToastSeverity::Success => IconRole::Check,
|
|
ToastSeverity::Warning | ToastSeverity::Danger => IconRole::Warning,
|
|
});
|
|
|
|
div()
|
|
.id(("toast", entry.id))
|
|
.flex()
|
|
.items_start()
|
|
.gap_2()
|
|
.py_2()
|
|
.px_3()
|
|
.w_full()
|
|
.bg(surface)
|
|
.border_1()
|
|
.border_color(border)
|
|
.rounded(radius)
|
|
.shadow(shadows.clone())
|
|
.when(toast.show_icon, |toast| {
|
|
toast.child(
|
|
div()
|
|
.flex_none()
|
|
.flex()
|
|
.items_center()
|
|
.h(window.line_height())
|
|
.child(icon(icon_role).size(icon_size).color(accent)),
|
|
)
|
|
})
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.gap_0p5()
|
|
.child(
|
|
div()
|
|
.text_size(theme.sizes.small.text_size)
|
|
.text_color(foreground)
|
|
.when(toast.message.is_some(), |toast| {
|
|
toast.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
})
|
|
.child(toast.title.clone()),
|
|
)
|
|
.when_some(toast.message.clone(), |toast, message| {
|
|
toast.child(
|
|
div()
|
|
.text_size(theme.sizes.small.text_size)
|
|
.text_color(muted_foreground)
|
|
.child(message),
|
|
)
|
|
}),
|
|
)
|
|
.child(
|
|
div()
|
|
.id(("toast-close", entry.id))
|
|
.flex_none()
|
|
.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(entry.id, cx);
|
|
});
|
|
}),
|
|
)
|
|
})),
|
|
)
|
|
}
|
|
}
|