diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 61254eb..7a77731 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -63,7 +63,7 @@ badge = [] input = ["button"] clipboard = ["button"] popover = [] -scroll_area = [] +scroll_area = ["scrollbar"] scrollbar = ["scroll_area"] segmented = [] separator = [] diff --git a/crates/core/src/alert.rs b/crates/core/src/alert.rs index 8af6b5a..9a8702f 100644 --- a/crates/core/src/alert.rs +++ b/crates/core/src/alert.rs @@ -332,6 +332,7 @@ impl RenderOnce for AlertsView { div() .relative() .w_full() + .debug_selector(|| "alert-entry".to_string()) .child(entry.alert.clone()) .with_animation( ("alert-enter", id), diff --git a/crates/core/src/dialog.rs b/crates/core/src/dialog.rs index 6aba2f4..a96a851 100644 --- a/crates/core/src/dialog.rs +++ b/crates/core/src/dialog.rs @@ -14,6 +14,7 @@ use crate::common::v_flex; pub type ChangeHandler = Rc; type ContentFn = Box AnyElement>; type ActionsFn = Box Vec>; + /// A centred panel over a dim scrim: the platform's modal. #[derive(IntoElement)] pub struct Dialog { diff --git a/crates/core/src/toast.rs b/crates/core/src/toast.rs index c0e8045..e8f25ac 100644 --- a/crates/core/src/toast.rs +++ b/crates/core/src/toast.rs @@ -275,6 +275,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity, cx: &App) -> gpui::Stateful div() .id(("toast", id)) + .debug_selector(|| "toast-pill".to_string()) .max_w(px(480.)) .flex() .items_center() diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index 0269a3a..0a1643a 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -17,7 +17,7 @@ gpui = { workspace = true, features = ["test-support"] } [features] default = ["full"] -full = ["dock", "resize_handle", "resizable", "shell", "window_controls"] +full = ["dock", "resize_handle", "resizable", "shell", "window_controls", "toast", "alert"] dock = [ "resize_handle", "dep:gpui-native-core", @@ -36,4 +36,6 @@ shell = [ "dep:gpui-native-data", "dep:gpui-native-icons", ] +toast = ["shell", "gpui-native-core/toast"] +alert = ["shell", "gpui-native-core/alert"] window_controls = ["dep:gpui-native-icons"] diff --git a/crates/layout/src/shell.rs b/crates/layout/src/shell.rs index e718c70..7234422 100644 --- a/crates/layout/src/shell.rs +++ b/crates/layout/src/shell.rs @@ -6,8 +6,12 @@ use gpui::{ AnyElement, App, AppContext as _, Context, ElementId, Entity, InteractiveElement as _, 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::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_icons::IconRole; use gpui_native_theme::{ActiveTheme as _, Size}; @@ -29,16 +33,24 @@ pub struct LayoutState { right_visible: bool, left_width: Pixels, right_width: Pixels, + #[cfg(feature = "toast")] + toasts: Entity, + #[cfg(feature = "alert")] + alerts: Entity, } impl LayoutState { /// Creates the state: left sidebar visible, right sidebar hidden. pub fn new(cx: &mut App) -> Entity { - cx.new(|_| Self { + cx.new(|cx| Self { left_visible: true, right_visible: false, left_width: px(DEFAULT_SIDEBAR_WIDTH), right_width: px(DEFAULT_SIDEBAR_WIDTH), + #[cfg(feature = "toast")] + toasts: cx.new(|_| Toasts::default()), + #[cfg(feature = "alert")] + alerts: cx.new(|_| Alerts::default()), }) } @@ -91,6 +103,42 @@ impl LayoutState { (self.right_width - delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH)); 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) -> 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.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.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) -> 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.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.alerts.update(cx, |alerts, cx| alerts.clear(cx)); + } } /// Which sidebar a region or toggle belongs to. @@ -241,8 +289,22 @@ impl RenderOnce for Shell { )) }); - // Wraps the layout in a window frame. - frame(layout, window, cx) + // Wraps the layout in a window frame + let mut shell = frame(layout, window, cx); + + #[cfg(feature = "toast")] + { + let toasts = state.read(cx).toasts.clone(); + shell = shell.child(ToastsView::new((id.clone(), "toasts"), toasts)); + } + + #[cfg(feature = "alert")] + { + let alerts = state.read(cx).alerts.clone(); + shell = shell.child(AlertsView::new((id, "alerts"), alerts)); + } + + shell } } diff --git a/crates/layout/tests/shell.rs b/crates/layout/tests/shell.rs index 95583a1..2867d8a 100644 --- a/crates/layout/tests/shell.rs +++ b/crates/layout/tests/shell.rs @@ -235,3 +235,88 @@ fn the_right_seam_grows_the_right_sidebar_leftwards() { let region = bounds(&mut visual, "test-right").expect("right region missing"); 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" + ); + } +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index d7663a8..ef6bdd6 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -7,7 +7,7 @@ use gpui::{ SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds, WindowOptions, actions, div, list, px, 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::badge::{Badge, BadgeVariant}; 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::status_dot::{StatusDot, StatusDotVariant}; 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::tooltip::Tooltip; use gpui_native_data::list::{List, ListItem}; @@ -217,8 +217,6 @@ fn gallery_commands() -> Vec { struct Gallery { layout_state: Entity, dock_state: Entity, - alerts: Entity, - toasts: Entity, palette: Option>, palette_runs: Vec, dialog_open: bool, @@ -395,8 +393,6 @@ impl Gallery { fn register_panels( dock: &mut DockState, layout_state: Entity, - alerts: Entity, - toasts: Entity, palette: Entity, gallery: WeakEntity, cx: &mut Context, @@ -513,20 +509,20 @@ impl Gallery { .into() })); dock.register_panel(PanelDescriptor::new("alert", "Alert", { - let alerts = alerts.clone(); + let layout_state = layout_state.clone(); move |_, cx| { cx.new(|_| AlertDemo { - alerts: alerts.clone(), + layout_state: layout_state.clone(), dismissed: false, }) .into() } })); dock.register_panel(PanelDescriptor::new("toast", "Toast", { - let toasts = toasts.clone(); + let layout_state = layout_state.clone(); move |_, cx| { cx.new(|_| ToastDemo { - toasts: toasts.clone(), + layout_state: layout_state.clone(), }) .into() } @@ -649,8 +645,6 @@ impl Render for Gallery { .child(dialog) .child(alert_dialog) .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())) } } @@ -2287,16 +2281,16 @@ impl Render for TabsDemo { } struct AlertDemo { - alerts: Entity, + layout_state: Entity, dismissed: bool, } impl Render for AlertDemo { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); - let alerts_info = self.alerts.clone(); - let alerts_warning = self.alerts.clone(); - let alerts_clear = self.alerts.clone(); + let alerts_info = self.layout_state.clone(); + let alerts_warning = self.layout_state.clone(); + let alerts_clear = self.layout_state.clone(); let colors = cx.theme().colors; ScrollArea::new("alert-demo").size_full().p_4().child( @@ -2339,8 +2333,8 @@ impl Render for AlertDemo { .label("Push info") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { - alerts_info.update(cx, |alerts, cx| { - alerts.push( + alerts_info.update(cx, |state, cx| { + state.push_alert( Alert::new("alert-demo-pushed-info") .title("Backed up") .message("Everything is in sync.") @@ -2355,8 +2349,8 @@ impl Render for AlertDemo { .label("Push warning") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { - alerts_warning.update(cx, |alerts, cx| { - alerts.push( + alerts_warning.update(cx, |state, cx| { + state.push_alert( Alert::new("alert-demo-pushed-warning") .title("Storage almost full") .message("1.2 GB remaining on this device.") @@ -2371,7 +2365,7 @@ impl Render for AlertDemo { .label("Clear all") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { - alerts_clear.update(cx, |alerts, cx| alerts.clear(cx)); + alerts_clear.update(cx, |state, cx| state.clear_alerts(cx)); }), ), )), @@ -2380,14 +2374,14 @@ impl Render for AlertDemo { } struct ToastDemo { - toasts: Entity, + layout_state: Entity, } impl Render for ToastDemo { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let toasts_undo = self.toasts.clone(); - let toasts_saved = self.toasts.clone(); - let toasts_connection = self.toasts.clone(); + let toasts_undo = self.layout_state.clone(); + let toasts_saved = self.layout_state.clone(); + let toasts_connection = self.layout_state.clone(); let colors = cx.theme().colors; ScrollArea::new("toast-demo") @@ -2404,8 +2398,8 @@ impl Render for ToastDemo { .label("Deleted (with undo)") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { - toasts_undo.update(cx, |toasts, cx| { - toasts.push( + toasts_undo.update(cx, |state, cx| { + state.push_toast( Toast::new("Draft deleted").action(ToastAction::new( "Undo", |cx| { @@ -2423,8 +2417,8 @@ impl Render for ToastDemo { .label("Saved (with icon)") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { - toasts_saved.update(cx, |toasts, cx| { - toasts.push( + toasts_saved.update(cx, |state, cx| { + state.push_toast( Toast::new("Workspace saved").icon(IconRole::Check), cx, ); @@ -2436,8 +2430,8 @@ impl Render for ToastDemo { .label("Connection lost (sticky)") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { - toasts_connection.update(cx, |toasts, cx| { - toasts.push( + toasts_connection.update(cx, |state, cx| { + state.push_toast( Toast::new("Connection lost") .icon(IconRole::Warning) .duration(Duration::ZERO), @@ -4025,24 +4019,14 @@ fn main() { cx.refresh_windows(); }) .detach(); - let toasts = cx.new(|_| Toasts::default()); - let alerts = cx.new(|_| Alerts::default()); - let gallery = cx.new(|cx| { - // Toast pushes and dismissals repaint the stack. - cx.observe(&toasts, |_, _, cx| cx.notify()).detach(); - // Alert pushes and dismissals repaint it. - cx.observe(&alerts, |_, _, cx| cx.notify()).detach(); - Gallery { - layout_state: layout_state.clone(), - dock_state: dock_state.clone(), - alerts: alerts.clone(), - toasts: toasts.clone(), - palette: None, - palette_runs: Vec::new(), - dialog_open: false, - alert_open: false, - sidebar_transparent: true, - } + let gallery = cx.new(|_| Gallery { + layout_state: layout_state.clone(), + dock_state: dock_state.clone(), + palette: None, + palette_runs: Vec::new(), + dialog_open: false, + alert_open: false, + sidebar_transparent: true, }); let palette = cx.new(|cx| { let matcher = |query: &str, commands: &[Command]| { @@ -4076,8 +4060,6 @@ fn main() { Gallery::register_panels( dock, layout_state.clone(), - alerts.clone(), - toasts.clone(), palette.clone(), gallery.downgrade(), cx,