split toast into alert and toast

This commit is contained in:
2026-11-01 09:20:10 +07:00
parent 9ddf8fe90f
commit 5a32401e25
10 changed files with 954 additions and 235 deletions
+2
View File
@@ -32,6 +32,7 @@ full = [
"tooltip",
"menu",
"dialog",
"alert",
"toast",
]
button = []
@@ -49,4 +50,5 @@ scroll_area = []
tooltip = []
menu = ["scroll_area"]
dialog = []
alert = []
toast = []
+330
View File
@@ -0,0 +1,330 @@
use std::rc::Rc;
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 _, ThemeVariant};
use crate::common::DismissHandler;
use crate::common::v_flex;
/// How alarming an [`Alert`] is; picks the icon and the accent color.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AlertSeverity {
/// The default; uses the info role.
#[default]
Info,
/// Uses the success role.
Success,
/// Uses the warning role.
Warning,
/// Uses the destructive role.
Danger,
}
/// A static callout banner.
#[derive(Clone, IntoElement)]
pub struct Alert {
id: ElementId,
title: Option<SharedString>,
message: Option<SharedString>,
severity: AlertSeverity,
icon: Option<IconRole>,
show_icon: bool,
on_dismiss: Option<DismissHandler>,
}
impl Alert {
pub fn new<I>(id: I) -> Self
where
I: Into<ElementId>,
{
Self {
id: id.into(),
title: None,
message: None,
severity: AlertSeverity::Info,
icon: None,
show_icon: true,
on_dismiss: None,
}
}
/// Sets the primary line.
pub fn title<L>(mut self, title: L) -> Self
where
L: Into<SharedString>,
{
self.title = Some(title.into());
self
}
/// 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 alert is.
pub fn severity(mut self, severity: AlertSeverity) -> 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
}
/// Runs when the user dismisses the alert.
pub fn on_dismiss<F>(mut self, handler: F) -> Self
where
F: Fn(&mut App) + 'static,
{
self.on_dismiss = Some(Rc::new(handler));
self
}
fn rekey(mut self, id: usize) -> Self {
self.id = ElementId::from(id);
self
}
}
impl RenderOnce for Alert {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let ramp = match self.severity {
AlertSeverity::Info => theme.ramps.info,
AlertSeverity::Success => theme.ramps.success,
AlertSeverity::Warning => theme.ramps.warning,
AlertSeverity::Danger => theme.ramps.destructive,
};
let icon_role = self.icon.unwrap_or(match self.severity {
AlertSeverity::Info => IconRole::Info,
AlertSeverity::Success => IconRole::Check,
AlertSeverity::Warning | AlertSeverity::Danger => IconRole::Warning,
});
let foreground = theme.colors.foreground;
let muted_foreground = theme.colors.muted_foreground;
let muted = theme.colors.muted;
let surface = theme.colors.surface;
let border = theme.colors.border;
let mut elevation = theme.elevations.popover;
if theme.variant == ThemeVariant::Light {
elevation.color = elevation.color.alpha(0.12);
}
let shadows = elevation.shadows();
let text_size = theme.sizes.small.text_size;
let icon_size = theme.sizes.small.icon_size;
div()
.w_full()
.flex()
.items_start()
.gap_2()
.px_3()
.py_2()
.rounded(theme.radius.overlay)
.bg(surface)
.border_1()
.border_color(border)
.shadow(shadows)
.when(self.show_icon, |alert| {
alert.child(
div()
.flex_none()
.flex()
.items_center()
.h(window.line_height())
.child(icon(icon_role).size(icon_size).color(ramp.base)),
)
})
.child(
v_flex()
.flex_1()
.gap_0p5()
.when_some(self.title.clone(), |alert, title| {
alert.child(
div()
.text_size(text_size)
.text_color(foreground)
.when(self.message.is_some(), |title| {
title.font_weight(gpui::FontWeight::SEMIBOLD)
})
.child(title),
)
})
.when_some(self.message.clone(), |alert, message| {
// With no title, the message is the primary line.
let color = if self.title.is_some() {
muted_foreground
} else {
foreground
};
alert.child(div().text_size(text_size).text_color(color).child(message))
}),
)
.when_some(self.on_dismiss, |alert, on_dismiss| {
alert.child(
div()
.id((self.id, "alert-close"))
.flex_none()
.cursor_pointer()
.p_0p5()
.rounded(px(4.))
.hover(|style| style.bg(muted))
.child(
icon(IconRole::Close)
.size(theme.sizes.small.icon_size * 0.75)
.color(foreground.alpha(0.55)),
)
.on_click(move |_, _window, cx| on_dismiss(cx)),
)
})
}
}
/// One pushed alert, with its identity in the stack.
#[derive(Clone)]
pub struct AlertEntry {
pub id: usize,
pub alert: Alert,
on_dismiss: Option<DismissHandler>,
}
/// The stack of pushed alerts.
#[derive(Default)]
pub struct Alerts {
entries: Vec<AlertEntry>,
next_id: usize,
}
impl Alerts {
/// Pushes an alert onto the stack.
pub fn push(&mut self, mut alert: Alert, cx: &mut Context<Self>) -> usize {
let id = self.next_id;
self.next_id += 1;
let on_dismiss = alert.on_dismiss.take();
let stack = cx.entity().downgrade();
alert.on_dismiss = Some(Rc::new(move |cx: &mut App| {
stack.update(cx, |alerts, cx| alerts.dismiss(id, cx)).ok();
}));
self.entries.push(AlertEntry {
id,
alert: alert.rekey(id),
on_dismiss,
});
cx.notify();
id
}
/// Dismisses one alert 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.on_dismiss {
(on_dismiss.clone())(cx);
}
}
}
/// Dismisses every alert.
pub fn clear(&mut self, cx: &mut Context<Self>) {
if !self.entries.is_empty() {
self.entries.clear();
cx.notify();
}
}
/// The stacked alerts, oldest first.
pub fn entries(&self) -> &[AlertEntry] {
&self.entries
}
/// How many alerts are stacked.
pub fn count(&self) -> usize {
self.entries.len()
}
}
/// The floating stack of pushed alerts, topmost right.
#[derive(IntoElement)]
pub struct AlertsView {
alerts: Entity<Alerts>,
id: ElementId,
}
impl AlertsView {
/// Binds the view to an alert stack.
pub fn new<I>(id: I, alerts: Entity<Alerts>) -> Self
where
I: Into<ElementId>,
{
Self {
id: id.into(),
alerts,
}
}
}
impl RenderOnce for AlertsView {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let enter_duration = theme.motion.overlay;
let entries: Vec<AlertEntry> = self.alerts.read(cx).entries().to_vec();
deferred(
div()
.id(self.id)
.absolute()
.top_4()
.right_4()
.w_80()
.flex()
.flex_col()
.items_end()
.gap_2()
.children(entries.iter().rev().map(|entry| {
let id = entry.id;
let ease_slide = ease_out_quint();
div()
.relative()
.w_full()
.child(entry.alert.clone())
.with_animation(
("alert-enter", id),
Animation::new(enter_duration),
move |layer, delta| layer.left(px(24. * (1. - ease_slide(delta)))),
)
})),
)
}
}
+3
View File
@@ -3,6 +3,9 @@
pub use gpui_native_theme::Size;
pub use gpui_native_utils::{h_flex, v_flex};
/// A handler told to clean up after a dismissed surface.
pub type DismissHandler = std::rc::Rc<dyn Fn(&mut gpui::App)>;
/// A component that can be told which size to render at.
pub trait Sizable: Sized {
/// Sets the size.
+3
View File
@@ -42,6 +42,9 @@ pub mod dialog;
#[cfg(feature = "menu")]
pub mod menu;
#[cfg(feature = "alert")]
pub mod alert;
#[cfg(feature = "popover")]
pub mod popover;
+216 -176
View File
@@ -1,3 +1,7 @@
//! 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;
@@ -10,34 +14,38 @@ use gpui::{
use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::ActiveTheme as _;
use crate::common::v_flex;
use crate::common::DismissHandler;
/// The dismiss handler of a [`Toast`].
pub type DismissHandler = Rc<dyn Fn(&mut App)>;
/// The action handler of a [`Toast`].
pub type ActionHandler = 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,
/// One user action offered by a toast.
#[derive(Clone)]
pub struct ToastAction {
label: SharedString,
handler: ActionHandler,
}
/// Toast notification.
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 {
title: SharedString,
message: Option<SharedString>,
severity: ToastSeverity,
/// The painted glyph; `None` derives it from the severity.
message: SharedString,
icon: Option<IconRole>,
show_icon: bool,
action: Option<ToastAction>,
duration: Option<Duration>,
on_dismiss: Option<DismissHandler>,
}
@@ -46,46 +54,29 @@ 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
/// Creates a toast with this one-line content.
pub fn new<L>(message: L) -> Self
where
L: Into<SharedString>,
{
Self {
title: title.into(),
message: None,
severity: ToastSeverity::Info,
message: message.into(),
icon: None,
show_icon: true,
action: None,
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.
/// Shows this glyph before the content.
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;
/// Offers the user this action.
pub fn action(mut self, action: ToastAction) -> Self {
self.action = Some(action);
self
}
@@ -95,7 +86,7 @@ impl Toast {
self
}
/// Runs when the toast is dismissed by any means.
/// 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,
@@ -115,19 +106,24 @@ pub struct ToastEntry {
/// The store of raised toasts.
#[derive(Default)]
pub struct Toasts {
entries: Vec<ToastEntry>,
current: Option<ToastEntry>,
exiting: Option<ToastEntry>,
next_id: usize,
}
impl Toasts {
/// Raises a toast, starting its auto-dismiss timer. Returns its id.
/// 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;
self.entries.push(ToastEntry { id, toast });
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()) {
@@ -141,34 +137,65 @@ impl Toasts {
id
}
/// Dismisses one toast by id, running its dismiss handler.
/// Dismisses one toast by id.
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);
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(cx);
(on_dismiss.clone())(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)]
@@ -191,118 +218,131 @@ impl ToastsView {
}
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 enter_duration = theme.motion.overlay;
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let fade = cx.theme().motion.overlay;
let entries: Vec<ToastEntry> = self.toasts.read(cx).entries().to_vec();
let exiting = self.toasts.read(cx).exiting.clone();
let current = self.toasts.read(cx).current.clone();
deferred(
div()
.id(self.id)
.absolute()
.top_4()
.right_4()
.left_0()
.bottom_4()
.w_full()
.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.info.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);
});
}),
)
.with_animation(
("toast-enter", entry.id),
Animation::new(enter_duration).with_easing(ease_out_quint()),
|toast, delta| toast.mt(px(8. * (1. - delta))),
)
})),
.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);
});
}),
)
}
+113
View File
@@ -0,0 +1,113 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
AnyWindowHandle, AppContext as _, InteractiveElement as _, IntoElement, Modifiers,
ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
px,
};
use gpui_native_core::alert::{Alert, AlertSeverity};
use gpui_native_theme::Theme;
struct Harness {
severity: AlertSeverity,
dismissed: Rc<RefCell<bool>>,
}
impl Render for Harness {
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
let dismissed = self.dismissed.clone();
div().size_full().p_4().child(
div().debug_selector(|| "test-alert".to_string()).child(
Alert::new("test-alert")
.title("Heads up")
.severity(self.severity)
.message("Secondary line.")
.on_dismiss(move |_| *dismissed.borrow_mut() = true),
),
)
}
}
/// The alert stretches across the top of the content area; its close button
/// sits in the banner's top-right corner.
fn alert_window(
severity: AlertSeverity,
) -> (VisualTestContext, AnyWindowHandle, Rc<RefCell<bool>>) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let dismissed = Rc::new(RefCell::new(false));
let harness_dismissed = dismissed.clone();
let window = cx.add_window(move |_, _| Harness {
severity,
dismissed: harness_dismissed,
});
let window = window.into();
cx.update_window(window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
(
VisualTestContext::from_window(window, &cx),
window,
dismissed,
)
}
/// A banner with only a message paints, so the title stays optional.
#[test]
fn a_message_only_alert_renders() {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let window = cx.add_window(|_, _| MessageOnlyHarness);
let window = window.into();
let mut cx = VisualTestContext::from_window(window, &cx);
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
assert!(cx.debug_bounds("message-only-alert").is_some());
}
struct MessageOnlyHarness;
impl Render for MessageOnlyHarness {
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
div().size_full().p_4().child(
div()
.debug_selector(|| "message-only-alert".to_string())
.child(Alert::new("message-only").message("Only line.")),
)
}
}
/// Clicking the close button is how the user acts on the alert; the banner
/// reports the intent instead of hiding itself.
#[test]
fn closing_the_alert_reports_dismissal() {
let (mut cx, _window, dismissed) = alert_window(AlertSeverity::Danger);
let bounds = cx.debug_bounds("test-alert").expect("alert rendered");
cx.simulate_click(
point(bounds.right() - px(14.), bounds.top() + px(14.)),
Modifiers::default(),
);
assert!(*dismissed.borrow(), "the close button reported dismissal");
}
/// Every severity paints a banner.
#[test]
fn every_severity_renders_a_banner() {
for severity in [
AlertSeverity::Info,
AlertSeverity::Success,
AlertSeverity::Warning,
AlertSeverity::Danger,
] {
let (mut cx, _window, _dismissed) = alert_window(severity);
assert!(
cx.debug_bounds("test-alert").is_some(),
"{severity:?} alert rendered"
);
}
}
+72
View File
@@ -0,0 +1,72 @@
use std::rc::Rc;
use gpui::{AppContext as _, TestAppContext};
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts};
use gpui_native_theme::Theme;
fn alerts_stack() -> (TestAppContext, gpui::Entity<Alerts>) {
let cx = TestAppContext::single();
cx.update(Theme::init);
let alerts = cx.update(|cx| cx.new(|_| Alerts::default()));
(cx, alerts)
}
#[test]
fn pushed_alerts_stack_up_and_dismiss_by_id() {
let (cx, alerts) = alerts_stack();
cx.update(|cx| {
alerts.update(cx, |alerts, cx| {
alerts.push(
Alert::new("a").title("First").severity(AlertSeverity::Info),
cx,
);
alerts.push(
Alert::new("b")
.title("Second")
.severity(AlertSeverity::Warning),
cx,
);
});
});
assert_eq!(cx.update(|cx| alerts.read(cx).count()), 2);
cx.update(|cx| {
alerts.update(cx, |alerts, cx| alerts.dismiss(0, cx));
});
assert_eq!(cx.update(|cx| alerts.read(cx).count()), 1);
// Duplicate ids are fine: the stack re-keys each entry to its own id.
cx.update(|cx| {
alerts.update(cx, |alerts, cx| {
alerts.push(Alert::new("b").title("Third"), cx);
});
});
assert_eq!(cx.update(|cx| alerts.read(cx).count()), 2);
}
#[test]
fn the_close_button_routes_through_the_store_and_runs_the_handler() {
let (cx, alerts) = alerts_stack();
let dismissed = Rc::new(std::cell::RefCell::new(false));
cx.update(|cx| {
let handler = dismissed.clone();
alerts.update(cx, |alerts, cx| {
alerts.push(
Alert::new("a").title("Sync failed").on_dismiss(move |_| {
*handler.borrow_mut() = true;
}),
cx,
);
});
});
// The pushed alert's close button calls the store's dismissal, which
// then runs the handler.
cx.update(|cx| {
alerts.update(cx, |alerts, cx| alerts.dismiss(0, cx));
});
assert!(*dismissed.borrow());
assert_eq!(cx.update(|cx| alerts.read(cx).count()), 0);
}
+97 -24
View File
@@ -3,7 +3,7 @@ use std::rc::Rc;
use std::time::Duration;
use gpui::{AppContext as _, TestAppContext};
use gpui_native_core::toast::{Toast, ToastSeverity, Toasts};
use gpui_native_core::toast::{Toast, ToastAction, Toasts};
use gpui_native_theme::Theme;
fn toasts_store() -> (TestAppContext, gpui::Entity<Toasts>) {
@@ -14,23 +14,49 @@ fn toasts_store() -> (TestAppContext, gpui::Entity<Toasts>) {
}
#[test]
fn push_raises_a_toast_and_the_timer_dismisses_it() {
fn push_raises_a_toast_and_the_timer_fades_it_out() {
let (cx, toasts) = toasts_store();
let id = cx.update(|cx| {
toasts.update(cx, |toasts, cx| {
toasts.push(Toast::new("Saved").severity(ToastSeverity::Success), cx)
})
});
let id = cx.update(|cx| toasts.update(cx, |toasts, cx| toasts.push(Toast::new("Saved"), cx)));
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1);
// The default lifetime is five seconds.
// The default lifetime is five seconds; the fade-out follows it.
cx.executor().advance_clock(Toast::DURATION);
cx.run_until_parked();
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1, "fading out");
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
let _ = id;
}
#[test]
fn pushing_replaces_the_toast_on_screen() {
let (cx, toasts) = toasts_store();
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.push(Toast::new("first"), cx));
});
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.push(Toast::new("second"), cx));
});
// One showing, one fading out: never two current toasts.
let (current, total) = cx.update(|cx| {
toasts.read_with(cx, |toasts, _| {
(toasts.current().map(|entry| entry.id), toasts.count())
})
});
assert_eq!(current, Some(1));
assert_eq!(total, 2);
cx.executor()
.advance_clock(std::time::Duration::from_secs(6));
cx.run_until_parked();
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
}
#[test]
fn sticky_toasts_ignore_the_timer_until_dismissed() {
let (cx, toasts) = toasts_store();
@@ -48,42 +74,89 @@ fn sticky_toasts_ignore_the_timer_until_dismissed() {
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.dismiss(id, cx));
});
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1, "fading out");
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
}
#[test]
fn dismiss_runs_the_handler_and_clear_removes_everything() {
fn dismiss_runs_the_handler_only_after_the_fade_completes() {
let (cx, toasts) = toasts_store();
let dismissed = Rc::new(std::cell::RefCell::new(Vec::<&'static str>::new()));
let ids = cx.update(|cx| {
cx.update(|cx| {
let first = dismissed.clone();
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,
),
]
})
toasts.push(
Toast::new("first").on_dismiss(move |_| record(&first, "first")),
cx,
);
});
});
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.dismiss(ids[0], cx));
toasts.update(cx, |toasts, cx| toasts.dismiss(0, cx));
});
assert!(
dismissed.borrow().is_empty(),
"the handler waits out the fade"
);
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
assert_eq!(*dismissed.borrow(), vec!["first"]);
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.clear(cx));
});
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
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);
}
}
/// Dismissing by any other path never runs the action: only a click on the
/// action itself does (the view wires the two together).
#[test]
fn dismissal_does_not_run_the_action() {
let (cx, toasts) = toasts_store();
let ran = Rc::new(std::cell::RefCell::new(false));
let dismissed = Rc::new(std::cell::RefCell::new(false));
cx.update(|cx| {
let action_ran = ran.clone();
let toast_dismissed = dismissed.clone();
toasts.update(cx, |toasts, cx| {
toasts.push(
Toast::new("Draft deleted")
.action(ToastAction::new("Undo", move |_| {
*action_ran.borrow_mut() = true;
}))
.on_dismiss(move |_| *toast_dismissed.borrow_mut() = true),
cx,
);
});
});
cx.update(|cx| {
toasts.update(cx, |toasts, cx| toasts.dismiss(0, cx));
});
assert!(!*ran.borrow(), "dismiss is not the action");
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
assert!(
*dismissed.borrow(),
"on_dismiss still reports the dismissal"
);
assert!(
*dismissed.borrow(),
"on_dismiss still reports the dismissal"
);
}