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
+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"
);
}