feat: host toasts and alerts in the shell

This commit is contained in:
2026-11-10 18:33:56 +07:00
parent a6b6cfd5a4
commit 67f34ead74
7 changed files with 182 additions and 60 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ input = ["button"]
clipboard = ["button"] clipboard = ["button"]
popover = [] popover = []
scroll_area = ["scrollbar"] scroll_area = ["scrollbar"]
scrollbar = [] scrollbar = ["scroll_area"]
segmented = [] segmented = []
separator = [] separator = []
spinner = [] spinner = []
+1
View File
@@ -338,6 +338,7 @@ impl RenderOnce for AlertsView {
div() div()
.relative() .relative()
.w_full() .w_full()
.debug_selector(|| "alert-entry".to_string())
.child(entry.alert.clone()) .child(entry.alert.clone())
.with_animation( .with_animation(
("alert-enter", id), ("alert-enter", id),
+1
View File
@@ -283,6 +283,7 @@ impl ToastEntry {
div() div()
.id(("toast", id)) .id(("toast", id))
.debug_selector(|| "toast-pill".to_string())
.role(Role::Alert) .role(Role::Alert)
.aria_label(toast.message.clone()) .aria_label(toast.message.clone())
.max_w(TOAST_MAX_WIDTH) .max_w(TOAST_MAX_WIDTH)
+3 -1
View File
@@ -17,7 +17,7 @@ gpui = { workspace = true, features = ["test-support"] }
[features] [features]
default = ["full"] default = ["full"]
full = ["dock", "resize_handle", "resizable", "shell", "window_controls"] full = ["dock", "resize_handle", "resizable", "shell", "window_controls", "toast", "alert"]
dock = [ dock = [
"resize_handle", "resize_handle",
"dep:gpui-native-core", "dep:gpui-native-core",
@@ -37,4 +37,6 @@ shell = [
"dep:gpui-native-data", "dep:gpui-native-data",
"dep:gpui-native-icons", "dep:gpui-native-icons",
] ]
toast = ["shell", "gpui-native-core/toast"]
alert = ["shell", "gpui-native-core/alert"]
window_controls = ["dep:gpui-native-icons"] window_controls = ["dep:gpui-native-icons"]
+58 -2
View File
@@ -6,8 +6,12 @@ use gpui::{
AnyElement, App, AppContext as _, Context, ElementId, Entity, InteractiveElement as _, AnyElement, App, AppContext as _, Context, ElementId, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, Pixels, RenderOnce, Styled as _, Window, div, px, IntoElement, ParentElement as _, Pixels, RenderOnce, Styled as _, Window, div, px,
}; };
#[cfg(feature = "alert")]
use gpui_native_core::alert::{Alert, Alerts, AlertsView};
use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; use gpui_native_core::common::{Sizable as _, h_flex, v_flex};
#[cfg(feature = "toast")]
use gpui_native_core::toast::{Toast, Toasts, ToastsView};
use gpui_native_data::sidebar::Sidebar; use gpui_native_data::sidebar::Sidebar;
use gpui_native_icons::IconRole; use gpui_native_icons::IconRole;
use gpui_native_theme::{ use gpui_native_theme::{
@@ -24,16 +28,24 @@ pub struct LayoutState {
right_visible: bool, right_visible: bool,
left_width: Pixels, left_width: Pixels,
right_width: Pixels, right_width: Pixels,
#[cfg(feature = "toast")]
toasts: Entity<Toasts>,
#[cfg(feature = "alert")]
alerts: Entity<Alerts>,
} }
impl LayoutState { impl LayoutState {
/// Creates the state: left sidebar visible, right sidebar hidden. /// Creates the state: left sidebar visible, right sidebar hidden.
pub fn new(cx: &mut App) -> Entity<Self> { pub fn new(cx: &mut App) -> Entity<Self> {
cx.new(|_| Self { cx.new(|_cx| Self {
left_visible: true, left_visible: true,
right_visible: false, right_visible: false,
left_width: SIDEBAR_DEFAULT_WIDTH, left_width: SIDEBAR_DEFAULT_WIDTH,
right_width: SIDEBAR_DEFAULT_WIDTH, right_width: SIDEBAR_DEFAULT_WIDTH,
#[cfg(feature = "toast")]
toasts: _cx.new(|_| Toasts::default()),
#[cfg(feature = "alert")]
alerts: _cx.new(|_| Alerts::default()),
}) })
} }
@@ -90,6 +102,42 @@ impl LayoutState {
cx.notify(); cx.notify();
} }
} }
/// Raises a toast in the shell's overlay. Returns its id.
#[cfg(feature = "toast")]
pub fn push_toast(&mut self, toast: Toast, cx: &mut Context<Self>) -> usize {
self.toasts.update(cx, |toasts, cx| toasts.push(toast, cx))
}
/// Dismisses one toast by id.
#[cfg(feature = "toast")]
pub fn dismiss_toast(&mut self, id: usize, cx: &mut Context<Self>) {
self.toasts.update(cx, |toasts, cx| toasts.dismiss(id, cx));
}
/// Dismisses the toast on screen.
#[cfg(feature = "toast")]
pub fn clear_toasts(&mut self, cx: &mut Context<Self>) {
self.toasts.update(cx, |toasts, cx| toasts.clear(cx));
}
/// Pushes an alert onto the shell's stack. Returns its id.
#[cfg(feature = "alert")]
pub fn push_alert(&mut self, alert: Alert, cx: &mut Context<Self>) -> usize {
self.alerts.update(cx, |alerts, cx| alerts.push(alert, cx))
}
/// Dismisses one alert by id, running its dismiss handler.
#[cfg(feature = "alert")]
pub fn dismiss_alert(&mut self, id: usize, cx: &mut Context<Self>) {
self.alerts.update(cx, |alerts, cx| alerts.dismiss(id, cx));
}
/// Dismisses every alert, running each entry's `on_dismiss` handler.
#[cfg(feature = "alert")]
pub fn clear_alerts(&mut self, cx: &mut Context<Self>) {
self.alerts.update(cx, |alerts, cx| alerts.clear(cx));
}
} }
/// Which sidebar a region or toggle belongs to. /// Which sidebar a region or toggle belongs to.
@@ -245,8 +293,16 @@ impl RenderOnce for Shell {
)) ))
}); });
// Wraps the layout in a window frame. // Wraps the layout in a window frame
frame(layout, window, cx) frame(layout, window, cx)
.when(cfg!(feature = "toast"), |this| {
let toasts = state.read(cx).toasts.clone();
this.child(ToastsView::new((id.clone(), "toasts"), toasts))
})
.when(cfg!(feature = "alert"), |this| {
let alerts = state.read(cx).alerts.clone();
this.child(AlertsView::new((id, "alerts"), alerts))
})
} }
} }
+85
View File
@@ -237,3 +237,88 @@ fn the_right_seam_grows_the_right_sidebar_leftwards() {
let region = bounds(&mut visual, "test-right").expect("right region missing"); let region = bounds(&mut visual, "test-right").expect("right region missing");
assert_eq!(region.size.width, grown); assert_eq!(region.size.width, grown);
} }
/// Toasts and alerts live on the shell's [`LayoutState`]; pushing one must
/// show up in the rendered shell with no observing or manual overlay.
#[cfg(feature = "toast")]
mod toasts {
use super::*;
use gpui_native_core::toast::{Toast, ToastAction};
use std::time::Duration;
#[test]
fn a_pushed_toast_renders_in_the_shell() {
let mut cx = TestAppContext::single();
let (state, mut visual) = layout_window(&mut cx);
let id = cx.update(|cx| {
state.update(cx, |state, cx| {
state.push_toast(
Toast::new("Draft deleted").action(ToastAction::new("Undo", |_| {
// A real app restores the draft here.
})),
cx,
)
})
});
// No redraw, no observation: the shell repaints its own overlay.
cx.run_until_parked();
assert!(
bounds(&mut visual, "toast-pill").is_some(),
"the pushed toast must appear in the shell's overlay"
);
cx.update(|cx| {
state.update(cx, |state, cx| state.dismiss_toast(id, cx));
});
cx.run_until_parked();
// The fade-out keeps the pill mounted briefly.
assert!(
bounds(&mut visual, "toast-pill").is_some(),
"the dismissed toast must stay mounted through its fade-out"
);
cx.executor().advance_clock(Duration::from_secs(2));
cx.run_until_parked();
assert!(
bounds(&mut visual, "toast-pill").is_none(),
"the dismissed toast must leave the overlay once faded"
);
}
}
#[cfg(feature = "alert")]
mod alerts {
use super::*;
use gpui_native_core::alert::{Alert, AlertSeverity};
#[test]
fn a_pushed_alert_renders_in_the_shell() {
let mut cx = TestAppContext::single();
let (state, mut visual) = layout_window(&mut cx);
cx.update(|cx| {
state.update(cx, |state, cx| {
state.push_alert(
Alert::new("shell-alert")
.title("Backed up")
.severity(AlertSeverity::Info),
cx,
);
})
});
cx.run_until_parked();
assert!(
bounds(&mut visual, "alert-entry").is_some(),
"the pushed alert must appear in the shell's overlay"
);
cx.update(|cx| {
state.update(cx, |state, cx| state.clear_alerts(cx));
});
cx.run_until_parked();
assert!(
bounds(&mut visual, "alert-entry").is_none(),
"cleared alerts must leave the overlay"
);
}
}
+33 -56
View File
@@ -7,7 +7,7 @@ use gpui::{
SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds, SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds,
WindowOptions, actions, div, list, px, rems, size, uniform_list, WindowOptions, actions, div, list, px, rems, size, uniform_list,
}; };
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView}; use gpui_native_core::alert::{Alert, AlertSeverity};
use gpui_native_core::avatar::Avatar; use gpui_native_core::avatar::Avatar;
use gpui_native_core::badge::{Badge, BadgeVariant}; use gpui_native_core::badge::{Badge, BadgeVariant};
use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::button::{Button, ButtonVariant};
@@ -36,7 +36,7 @@ use gpui_native_core::slider::Slider;
use gpui_native_core::spinner::{Spinner, SpinnerVariant}; use gpui_native_core::spinner::{Spinner, SpinnerVariant};
use gpui_native_core::status_dot::{StatusDot, StatusDotVariant}; use gpui_native_core::status_dot::{StatusDot, StatusDotVariant};
use gpui_native_core::switch::Switch; use gpui_native_core::switch::Switch;
use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView}; use gpui_native_core::toast::{Toast, ToastAction};
use gpui_native_core::toggle::Toggle; use gpui_native_core::toggle::Toggle;
use gpui_native_core::tooltip::Tooltip; use gpui_native_core::tooltip::Tooltip;
use gpui_native_data::list::{List, ListItem}; use gpui_native_data::list::{List, ListItem};
@@ -219,15 +219,12 @@ fn gallery_commands() -> Vec<Command> {
struct Gallery { struct Gallery {
layout_state: Entity<LayoutState>, layout_state: Entity<LayoutState>,
dock_state: Entity<DockState>, dock_state: Entity<DockState>,
alerts: Entity<Alerts>,
toasts: Entity<Toasts>,
palette: Option<Entity<CommandPaletteState>>, palette: Option<Entity<CommandPaletteState>>,
palette_runs: Vec<SharedString>, palette_runs: Vec<SharedString>,
dialog_open: bool, dialog_open: bool,
alert_open: bool, alert_open: bool,
/// Whether the app sidebar paints the window's native material. /// Whether the app sidebar paints the window's native material.
sidebar_transparent: bool, sidebar_transparent: bool,
_subscriptions: Vec<gpui::Subscription>,
} }
impl Gallery { impl Gallery {
@@ -402,8 +399,6 @@ impl Gallery {
fn register_panels( fn register_panels(
dock: &mut DockState, dock: &mut DockState,
layout_state: Entity<LayoutState>, layout_state: Entity<LayoutState>,
alerts: Entity<Alerts>,
toasts: Entity<Toasts>,
palette: Entity<CommandPaletteState>, palette: Entity<CommandPaletteState>,
gallery: WeakEntity<Self>, gallery: WeakEntity<Self>,
cx: &mut Context<DockState>, cx: &mut Context<DockState>,
@@ -521,20 +516,20 @@ impl Gallery {
.into() .into()
})); }));
dock.register_panel(PanelDescriptor::new("alert", "Alert", { dock.register_panel(PanelDescriptor::new("alert", "Alert", {
let alerts = alerts.clone(); let layout_state = layout_state.clone();
move |_, cx| { move |_, cx| {
cx.new(|_| AlertDemo { cx.new(|_| AlertDemo {
alerts: alerts.clone(), layout_state: layout_state.clone(),
dismissed: false, dismissed: false,
}) })
.into() .into()
} }
})); }));
dock.register_panel(PanelDescriptor::new("toast", "Toast", { dock.register_panel(PanelDescriptor::new("toast", "Toast", {
let toasts = toasts.clone(); let layout_state = layout_state.clone();
move |_, cx| { move |_, cx| {
cx.new(|_| ToastDemo { cx.new(|_| ToastDemo {
toasts: toasts.clone(), layout_state: layout_state.clone(),
}) })
.into() .into()
} }
@@ -665,8 +660,6 @@ impl Render for Gallery {
.child(dialog) .child(dialog)
.child(alert_dialog) .child(alert_dialog)
.when_some(self.palette.clone(), |root, palette| root.child(palette)) .when_some(self.palette.clone(), |root, palette| root.child(palette))
.child(AlertsView::new("gallery-alerts", self.alerts.clone()))
.child(ToastsView::new("gallery-toasts", self.toasts.clone()))
} }
} }
@@ -2344,16 +2337,16 @@ impl Render for TabsDemo {
} }
struct AlertDemo { struct AlertDemo {
alerts: Entity<Alerts>, layout_state: Entity<LayoutState>,
dismissed: bool, dismissed: bool,
} }
impl Render for AlertDemo { impl Render for AlertDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity(); let view = cx.entity();
let alerts_info = self.alerts.clone(); let alerts_info = self.layout_state.clone();
let alerts_warning = self.alerts.clone(); let alerts_warning = self.layout_state.clone();
let alerts_clear = self.alerts.clone(); let alerts_clear = self.layout_state.clone();
let colors = cx.theme().colors; let colors = cx.theme().colors;
ScrollArea::new("alert-demo").size_full().child( ScrollArea::new("alert-demo").size_full().child(
@@ -2396,8 +2389,8 @@ impl Render for AlertDemo {
.label("Push info") .label("Push info")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
alerts_info.update(cx, |alerts, cx| { alerts_info.update(cx, |state, cx| {
alerts.push( state.push_alert(
Alert::new("alert-demo-pushed-info") Alert::new("alert-demo-pushed-info")
.title("Backed up") .title("Backed up")
.message("Everything is in sync.") .message("Everything is in sync.")
@@ -2412,8 +2405,8 @@ impl Render for AlertDemo {
.label("Push warning") .label("Push warning")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
alerts_warning.update(cx, |alerts, cx| { alerts_warning.update(cx, |state, cx| {
alerts.push( state.push_alert(
Alert::new("alert-demo-pushed-warning") Alert::new("alert-demo-pushed-warning")
.title("Storage almost full") .title("Storage almost full")
.message("1.2 GB remaining on this device.") .message("1.2 GB remaining on this device.")
@@ -2428,7 +2421,7 @@ impl Render for AlertDemo {
.label("Clear all") .label("Clear all")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
alerts_clear.update(cx, |alerts, cx| alerts.clear(cx)); alerts_clear.update(cx, |state, cx| state.clear_alerts(cx));
}), }),
), ),
)), )),
@@ -2437,14 +2430,14 @@ impl Render for AlertDemo {
} }
struct ToastDemo { struct ToastDemo {
toasts: Entity<Toasts>, layout_state: Entity<LayoutState>,
} }
impl Render for ToastDemo { impl Render for ToastDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let toasts_undo = self.toasts.clone(); let toasts_undo = self.layout_state.clone();
let toasts_saved = self.toasts.clone(); let toasts_saved = self.layout_state.clone();
let toasts_connection = self.toasts.clone(); let toasts_connection = self.layout_state.clone();
let colors = cx.theme().colors; let colors = cx.theme().colors;
ScrollArea::new("toast-demo") ScrollArea::new("toast-demo")
@@ -2460,8 +2453,8 @@ impl Render for ToastDemo {
.label("Deleted (with undo)") .label("Deleted (with undo)")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
toasts_undo.update(cx, |toasts, cx| { toasts_undo.update(cx, |state, cx| {
toasts.push( state.push_toast(
Toast::new("Draft deleted").action(ToastAction::new( Toast::new("Draft deleted").action(ToastAction::new(
"Undo", "Undo",
|cx| { |cx| {
@@ -2479,8 +2472,8 @@ impl Render for ToastDemo {
.label("Saved (with icon)") .label("Saved (with icon)")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
toasts_saved.update(cx, |toasts, cx| { toasts_saved.update(cx, |state, cx| {
toasts.push( state.push_toast(
Toast::new("Workspace saved").icon(IconRole::Check), Toast::new("Workspace saved").icon(IconRole::Check),
cx, cx,
); );
@@ -2492,8 +2485,8 @@ impl Render for ToastDemo {
.label("Connection lost (sticky)") .label("Connection lost (sticky)")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
toasts_connection.update(cx, |toasts, cx| { toasts_connection.update(cx, |state, cx| {
toasts.push( state.push_toast(
Toast::new("Connection lost") Toast::new("Connection lost")
.icon(IconRole::Warning) .icon(IconRole::Warning)
.duration(Duration::ZERO), .duration(Duration::ZERO),
@@ -4157,28 +4150,14 @@ fn main() {
cx.refresh_windows(); cx.refresh_windows();
}) })
.detach(); .detach();
let gallery = cx.new(|_| Gallery {
let toasts = cx.new(|_| Toasts::default()); layout_state: layout_state.clone(),
let alerts = cx.new(|_| Alerts::default()); dock_state: dock_state.clone(),
palette: None,
let gallery = cx.new(|cx| { palette_runs: Vec::new(),
Gallery { dialog_open: false,
layout_state: layout_state.clone(), alert_open: false,
dock_state: dock_state.clone(), sidebar_transparent: true,
alerts: alerts.clone(),
toasts: toasts.clone(),
palette: None,
palette_runs: Vec::new(),
dialog_open: false,
alert_open: false,
sidebar_transparent: true,
_subscriptions: vec![
// Toast pushes and dismissals repaint the stack.
cx.observe(&toasts, |_, _, cx| cx.notify()),
// Alert pushes and dismissals repaint it.
cx.observe(&alerts, |_, _, cx| cx.notify()),
],
}
}); });
let palette = cx.new(|cx| { let palette = cx.new(|cx| {
@@ -4216,8 +4195,6 @@ fn main() {
Gallery::register_panels( Gallery::register_panels(
dock, dock,
layout_state.clone(), layout_state.clone(),
alerts.clone(),
toasts.clone(),
palette.clone(), palette.clone(),
gallery.downgrade(), gallery.downgrade(),
cx, cx,