add toast

This commit is contained in:
2026-10-30 01:31:16 +07:00
parent e03b04793c
commit 64f4c3ff0c
13 changed files with 558 additions and 44 deletions
+1 -5
View File
@@ -179,11 +179,7 @@ impl RenderOnce for Button {
.text_color(foreground)
.when_some(a11y_label, |button, label| button.aria_label(label))
.when_some(self.icon, |button, role| {
button.child(
icon(role)
.size(f32::from(metrics.icon_size))
.color(foreground),
)
button.child(icon(role).size(metrics.icon_size).color(foreground))
})
.when_some(self.label, |button, label| button.child(label))
.when(self.disabled, |button| button.opacity(0.5))
+3
View File
@@ -48,6 +48,9 @@ pub mod popover;
#[cfg(feature = "scroll_area")]
pub mod scroll_area;
#[cfg(feature = "toast")]
pub mod toast;
#[cfg(feature = "tooltip")]
pub mod tooltip;
+2 -6
View File
@@ -495,17 +495,13 @@ fn render_item(
row.child(div().flex_none().when(checked, |slot| {
slot.child(
icon_glyph(IconRole::Check)
.size(f32::from(chrome.icon_size) * 0.75)
.size(chrome.icon_size * 0.75)
.color(label_color),
)
}))
})
.when_some(icon_role, |row, role| {
row.child(
icon_glyph(role)
.size(f32::from(chrome.icon_size))
.color(label_color),
)
row.child(icon_glyph(role).size(chrome.icon_size).color(label_color))
})
.child(
div()
+302
View File
@@ -0,0 +1,302 @@
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);
});
}),
)
})),
)
}
}
+89
View File
@@ -0,0 +1,89 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use gpui::{AppContext as _, TestAppContext};
use gpui_native_core::toast::{Toast, ToastSeverity, Toasts};
use gpui_native_theme::Theme;
fn toasts_store() -> (TestAppContext, gpui::Entity<Toasts>) {
let cx = TestAppContext::single();
cx.update(Theme::init);
let toasts = cx.update(|cx| cx.new(|_| Toasts::default()));
(cx, toasts)
}
#[test]
fn push_raises_a_toast_and_the_timer_dismisses_it() {
let (cx, toasts) = toasts_store();
let id = cx.update(|cx| {
toasts.update(cx, |toasts, cx| {
toasts.push(Toast::new("Saved").severity(ToastSeverity::Success), cx)
})
});
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1);
// The default lifetime is five seconds.
cx.executor().advance_clock(Toast::DURATION);
cx.run_until_parked();
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
let _ = id;
}
#[test]
fn sticky_toasts_ignore_the_timer_until_dismissed() {
let (cx, toasts) = toasts_store();
let id = cx.update(|cx| {
toasts.update(cx, |toasts, cx| {
toasts.push(Toast::new("Sticky").duration(Duration::ZERO), cx)
})
});
cx.executor().advance_clock(Toast::DURATION * 4);
cx.run_until_parked();
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1);
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.dismiss(id, cx));
});
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
}
#[test]
fn dismiss_runs_the_handler_and_clear_removes_everything() {
let (cx, toasts) = toasts_store();
let dismissed = Rc::new(std::cell::RefCell::new(Vec::<&'static str>::new()));
let ids = cx.update(|cx| {
toasts.update(cx, |toasts, cx| {
let first = dismissed.clone();
let second = dismissed.clone();
vec![
toasts.push(
Toast::new("first").on_dismiss(move |_| record(&first, "first")),
cx,
),
toasts.push(
Toast::new("second").on_dismiss(move |_| record(&second, "second")),
cx,
),
]
})
});
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.dismiss(ids[0], cx));
});
assert_eq!(*dismissed.borrow(), vec!["first"]);
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.clear(cx));
});
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
fn record(sink: &Rc<RefCell<Vec<&'static str>>>, what: &'static str) {
sink.borrow_mut().push(what);
}
}