From 5a32401e25c9dae9a86a824ec0b4ac72f2a8dee4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 1 Nov 2026 09:20:10 +0700 Subject: [PATCH] split toast into alert and toast --- crates/core/Cargo.toml | 2 + crates/core/src/alert.rs | 330 ++++++++++++++++++++++++++ crates/core/src/common.rs | 3 + crates/core/src/lib.rs | 3 + crates/core/src/toast.rs | 392 +++++++++++++++++-------------- crates/core/tests/alert.rs | 113 +++++++++ crates/core/tests/alert_stack.rs | 72 ++++++ crates/core/tests/toast.rs | 121 ++++++++-- docs/PLAN.md | 7 +- examples/gallery/src/main.rs | 146 +++++++++--- 10 files changed, 954 insertions(+), 235 deletions(-) create mode 100644 crates/core/src/alert.rs create mode 100644 crates/core/tests/alert.rs create mode 100644 crates/core/tests/alert_stack.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 6857f31..1fe5cc3 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -32,6 +32,7 @@ full = [ "tooltip", "menu", "dialog", + "alert", "toast", ] button = [] @@ -49,4 +50,5 @@ scroll_area = [] tooltip = [] menu = ["scroll_area"] dialog = [] +alert = [] toast = [] diff --git a/crates/core/src/alert.rs b/crates/core/src/alert.rs new file mode 100644 index 0000000..1c3a0f4 --- /dev/null +++ b/crates/core/src/alert.rs @@ -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, + message: Option, + severity: AlertSeverity, + icon: Option, + show_icon: bool, + on_dismiss: Option, +} + +impl Alert { + pub fn new(id: I) -> Self + where + I: Into, + { + 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(mut self, title: L) -> Self + where + L: Into, + { + self.title = Some(title.into()); + self + } + + /// Sets a secondary line under the title. + pub fn message(mut self, message: L) -> Self + where + L: Into, + { + 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(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, +} + +/// The stack of pushed alerts. +#[derive(Default)] +pub struct Alerts { + entries: Vec, + next_id: usize, +} + +impl Alerts { + /// Pushes an alert onto the stack. + pub fn push(&mut self, mut alert: Alert, cx: &mut Context) -> 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) { + 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) { + 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, + id: ElementId, +} + +impl AlertsView { + /// Binds the view to an alert stack. + pub fn new(id: I, alerts: Entity) -> Self + where + I: Into, + { + 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 = 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)))), + ) + })), + ) + } +} diff --git a/crates/core/src/common.rs b/crates/core/src/common.rs index b8ffabc..f5fcb54 100644 --- a/crates/core/src/common.rs +++ b/crates/core/src/common.rs @@ -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; + /// A component that can be told which size to render at. pub trait Sizable: Sized { /// Sets the size. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1e5cf7c..28bd568 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -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; diff --git a/crates/core/src/toast.rs b/crates/core/src/toast.rs index 1483063..6993bb6 100644 --- a/crates/core/src/toast.rs +++ b/crates/core/src/toast.rs @@ -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; +/// The action handler of a [`Toast`]. +pub type ActionHandler = Rc; -/// 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(label: L, handler: F) -> Self + where + L: Into, + 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, - severity: ToastSeverity, - /// The painted glyph; `None` derives it from the severity. + message: SharedString, icon: Option, - show_icon: bool, + action: Option, duration: Option, on_dismiss: Option, } @@ -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(title: L) -> Self + /// Creates a toast with this one-line content. + pub fn new(message: L) -> Self where L: Into, { 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(mut self, message: L) -> Self - where - L: Into, - { - 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(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, + current: Option, + exiting: Option, 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) -> 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) { - 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) { + 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) { + 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) { + 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) { - 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 = 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, cx: &App) -> gpui::Stateful { + 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); + }); + }), + ) +} diff --git a/crates/core/tests/alert.rs b/crates/core/tests/alert.rs new file mode 100644 index 0000000..fda0148 --- /dev/null +++ b/crates/core/tests/alert.rs @@ -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>, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context) -> 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>) { + 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) -> 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" + ); + } +} diff --git a/crates/core/tests/alert_stack.rs b/crates/core/tests/alert_stack.rs new file mode 100644 index 0000000..1d7db2a --- /dev/null +++ b/crates/core/tests/alert_stack.rs @@ -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) { + 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); +} diff --git a/crates/core/tests/toast.rs b/crates/core/tests/toast.rs index 7585bcc..95e099f 100644 --- a/crates/core/tests/toast.rs +++ b/crates/core/tests/toast.rs @@ -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) { @@ -14,23 +14,49 @@ fn toasts_store() -> (TestAppContext, gpui::Entity) { } #[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>>, 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" + ); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 9b2329b..a302441 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -156,7 +156,7 @@ The set stays role-minimal and grows one concept at a time, driven by the unit t - ~~**Overlay scrim + shadow/elevation tokens**~~ — ✅ landed with unit 11 (2026-10-29): `colors.overlay` (translucent-black scrim, denser in dark; TOML-overridable; no platform states one), `Radius.overlay` (10 pt), `Elevations.popover`/`dialog` (transcribed NSPopover-class shadows, builder → `Vec`), and `Motion.overlay` (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog. - ~~**Scrollbar thumb token + metrics**~~ — ✅ landed with the ScrollArea unit (2026-10-29): `colors.scrollbar_thumb` (translucent over content: black in light, light gray in dark; TOML-overridable) and `Theme.scrollbar` (`width` 8 pt, `inset` 2 pt, `min_thumb` 24 pt). -- ~~**`success` / `warning` roles**~~ — ✅ landed with Toast (2026-10-29): `colors.success`/`warning` (+ foregrounds; light and dark variants; TOML-overridable; no platform states them) and derived `Ramps.success`/`warning` via the OKLCH engine, matching the destructive treatment. **`info` joined them (2026-10-31)** — the fourth severity, same treatment (`colors.info` + foreground + `Ramps.info`, neutral sky blue, no platform states it, TOML `info =`). Trigger: `ToastSeverity::Info` was painting from the primary ramp, so a warm platform accent (GNOME red, Omarchy red themes) made Info read as Danger; the token keeps Info calm however the accent turns out. Severity tokens now use the same picked value for the ramp and the token (the success/warning tokens used to re-pick with the system slot dropped, diverging from their own ramps if a platform ever stated one). +- ~~**`success` / `warning` roles**~~ — ✅ landed with Toast (2026-10-29): `colors.success`/`warning` (+ foregrounds; light and dark variants; TOML-overridable; no platform states them) and derived `Ramps.success`/`warning` via the OKLCH engine, matching the destructive treatment. **`info` joined them (2026-10-31)** — the fourth severity, same treatment (`colors.info` + foreground + `Ramps.info`, neutral sky blue, no platform states it, TOML `info =`). Trigger: the Alert's Info severity was painting from the primary ramp, so a warm platform accent (GNOME red, Omarchy red themes) made Info read as Danger; the token keeps Info calm however the accent turns out. Severity tokens now use the same picked value for the ramp and the token (the success/warning tokens used to re-pick with the system slot dropped, diverging from their own ramps if a platform ever stated one). - **Motion tokens** — after the reverted framework attempt (2026-10-30), motion stays minimal: `Motion.overlay` (200 ms) is the only token. Components reach for gpui's own easings and `SpringConfig` inline (D21); a token is added only when a third component shares the same value (D16). TOML `[motion]` overrides and per-platform motion presets wait for a real need. ~~The reduce-motion forwarding fix~~ ✅ landed (pass a, 2026-10-30): `reapply` calls `cx.set_reduce_motion(resolved.accessibility.reduce_motion)`, so gpui's animation elements snap under the OS setting. - **`knob` / control-fill role** — only if Slider (unit 7) confirms the Switch's borrowed roles (`primary_foreground` thumb, `border` off-track) as a second consumer; until then no expansion. @@ -219,7 +219,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred. ✅ Fixed (2026-10-30): the caller's size refinement now lands on the outer wrapper too — previously it applied to the pane alone, so a percentage-sized pane resolved against an auto-height wrapper and a window-filling scroll area (the gallery's root shell) never actually scrolled; a headless repro of the gallery shell locks the behavior | | 12 | core | Menu | ✅ Landed (2026-10-29). Controlled `open`/`on_change` + `trigger`/`items` (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling | | 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app | -| 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. Toast: `Toasts` store entity (push/dismiss/clear + auto-dismiss timers on the GPUI executor, `Toast::DURATION` 5 s, zero = sticky) + `ToastsView` deferred stack (top-right, `Radius.overlay` + popover elevation, severity icon from the new success/warning ramps, close button, `on_dismiss` windowless handler). Toast landed the deferred `success`/`warning` tokens (§5.2). Sheet dropped from the roadmap | +| 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29), Alert + Toast rework ✅ (2026-10-31) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. **The 2026-10-31 rework split the old Toast in two (D23):** `Alert` (`alert` feature) — a persistent, fully controlled callout banner rendered inline where the app places it: severity icon (`Ramps..base` on a `surface` + `border` + `Radius.overlay` card with the popover shadow, halved in light mode so it doesn't pop on white), title and message both optional (a message-only banner paints it as the primary line), close button only when `on_dismiss` is set, reporting intent instead of hiding anything. A pushed, macOS-style stacking `Alerts`/`AlertsView` (collapse-slivers + hover-unroll + slide animations) was tried and rolled back the same day — inline-only won. The reworked `Toast` (`toast` feature) — **one at a time** (pushing replaces the one on screen), a succinct severity-less pill at the bottom center: optional icon + one-line truncating content + one optional action (`ToastAction`, the suggested-action pill) + an always-present close button, laid out as content, small gap, action, large gap, close; fades in and out (`Motion.overlay` each — exits via a store-held exiting slot whose handler runs once the fade completes). The store keeps GPUI-executor timers (`Toast::DURATION` 5 s, zero = sticky). Toast landed the deferred `success`/`warning` tokens (§5.2); Alert is the `info` token's consumer (D22). Sheet dropped from the roadmap | | 14 | core + theme | Motion (per component) | ✅ Landed in five passes (2026-10-30), replacing the reverted same-day framework attempt — no token tower, no `motion` module, no ghost panels; just gpui's own primitives used inline, one separately reviewed pass per component: (a) theme — `reapply` forwards `reduce_motion` via `cx.set_reduce_motion`, so gpui snaps every animation under the OS setting; (b) Switch — the thumb absolute-positions in the track and slides on `with_spring` (inline `SpringConfig::new(500, 35, 1)`, `Pixels` target, mounts at its target so the initial render never animates; velocity survives mid-flight retargets); (c) Toast — each toast slides up 8 px into its stack slot on `with_animation` at `Motion.overlay` with `ease_out_quint` (margin-driven, so only the entering toast moves; keyed per entry so the clock survives unrelated stack re-renders); (d) Dialog — the scrim enters with the panel (same timing and easing, its own `dialog-scrim-enter` animation id so the clocks start in the same frame); (e) Progress — the indeterminate sweep caps at 30 fps via `with_max_fps`. Overlay enter fades already existed from units 11/12. Exits and hover washes stay deferred — they were the complexity sink of the reverted attempt (§ deferred list) | | then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), Tabs ✅ (2026-10-30), Sidebar ✅ (2026-10-30) | Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (`selected: Option` + `on_change`); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed `FocusHandle` namespaced by the list id; roles List/ListItem with `aria_selected` and position/size-of-set; internal scrolling left to the caller's ScrollArea; `sheet` feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated `TreeItem`s (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via `on_disclosure(index)` (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with `aria_selected`/`aria_level`/`aria_expanded`/set-size; indent guides deferred. Tabs: a controlled strip only — `selected: usize` + `on_change`; the panel is the app's to render; active tab marked by an underline on the strip's hairline (bezel's model); arrows/left-right/home/end switch tabs with automatic activation, skipping disabled and never wrapping; re-selecting the active tab reports nothing; roles TabList/Tab with `aria_selected`; no wrap, no segmented-pill variant yet. Sidebar: List with section headers — `SidebarEntry::header(label)` / `row(label).icon().detail().disabled()`; headers are never selectable, selection indices address the flat entry list; same selection/keyboard/paint rules as List; roles List/ListItem (a sidebar is a list to accesskit, like macOS AXList); selection stays filled when unfocused (native) | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | @@ -314,7 +314,8 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio | D19 | System icons via semantic `IconRole`s with per-platform resolvers (SF Symbols / Segoe Fluent Icons / freedesktop) and a bundled Lucide fallback; platform glyph sets are used at runtime only, never bundled | | D20 | Components land one element at a time behind a review gate: API sketch → implementation → tests → gallery page → review → next. bezel and gpuikit are the API-design references for these units; conventions like controlled state, one activation path, semantic states, and a11y labels are adopted, simplified. gpui-kit is no longer consulted — judged over-engineered for our needs | | D21 | Motion is per component and inline: each component uses gpui's own animation/spring primitives (`with_animation`, `with_spring`, the gpui easings, `with_max_fps`) directly in its file; the theme owns only the durations components actually share (today `Motion.overlay`), growing per the role-minimal rule. No shared motion module, no exit ghosts, no hover-wash machinery — exits and washes stay out until a genuinely simple approach exists. Reduce motion is gpui-side: forward the OS flag once in `reapply` and every primitive snaps. Supersedes the reverted 2026-10-30 framework attempt (which proved the substrate but over-built the plumbing) | -| D22 | The color set does not expand along gpuikit's `Themeable` shape (evaluated 2026-10-31): their per-component token layers (`button_*`, `input_*`) are passthroughs of their own primitives — a drift-prone parallel layer our role-plus-ramp model replaces with OKLCH-derived hover/pressed shades; depth variants (`surface_secondary`), the badge palette, and `fg_disabled` wait for real consumers (Badge, nested elevation, a theme that can't express disabled by opacity). The one adoption: `info` as the fourth severity — `ToastSeverity::Info` had a live consumer painting from the primary ramp, which collides with Danger under warm accents. Domain palettes (editor diff colors) may revisit gpuikit's `ThemeExtension` idea when `crates/editor` lands | +| D22 | The color set does not expand along gpuikit's `Themeable` shape (evaluated 2026-10-31): their per-component token layers (`button_*`, `input_*`) are passthroughs of their own primitives — a drift-prone parallel layer our role-plus-ramp model replaces with OKLCH-derived hover/pressed shades; depth variants (`surface_secondary`), the badge palette, and `fg_disabled` wait for real consumers (Badge, nested elevation, a theme that can't express disabled by opacity). The one adoption: `info` as the fourth severity — the Alert's Info severity had a live consumer painting from the primary ramp, which collides with Danger under warm accents. Domain palettes (editor diff colors) may revisit gpuikit's `ThemeExtension` idea when `crates/editor` lands | +| D23 | The old Toast split into `Alert` + `Toast` (2026-10-31): one component can't be both a persistent page callout and a fleeting bottom-center nudge — different lifetime (persists until acted on vs auto-dismisses), different placement (inline in flow vs floating bottom center), different content shape (title + message + severity tint vs one line + icon + action). Alert is fully controlled — the app renders it while the condition holds and its close button reports intent; Toast keeps the store + timers. Toast drops severity and titles: Adwaita toasts are single-line nudges, and severity belongs to the banners that stay on the page | Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in a lighter form as D20. D10 (no icon bundling) is superseded by D19. diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index c2b2c63..5ae710a 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -6,6 +6,7 @@ use gpui::{ IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, WindowBounds, WindowOptions, div, px, size, }; +use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView}; use gpui_native_core::avatar::Avatar; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; @@ -20,7 +21,7 @@ use gpui_native_core::radio::{Radio, RadioGroup}; use gpui_native_core::scroll_area::ScrollArea; use gpui_native_core::slider::Slider; use gpui_native_core::switch::Switch; -use gpui_native_core::toast::{Toast, ToastSeverity, Toasts, ToastsView}; +use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView}; use gpui_native_core::toggle::Toggle; use gpui_native_core::tooltip::Tooltip; use gpui_native_data::list::{List, ListItem}; @@ -43,6 +44,8 @@ struct Gallery { menu_open: bool, dialog_open: bool, alert_open: bool, + update_alert_dismissed: bool, + alerts: Entity, toasts: Entity, scroll: gpui::ScrollHandle, list_selected: Option, @@ -550,21 +553,113 @@ impl Render for Gallery { v_flex() .gap_2() .child(section_label( - "toast · native ref — NSUserNotification stack, WinUI InfoBar, Adwaita in-app notifications", + "alert · static callout — Adwaita InfoBar, WinUI InfoBar", + colors, + )) + .child( + v_flex() + .gap_2() + .child( + Alert::new("gallery-alert-update").title("Update available") + .message("Version 2.4.0 is ready to install.") + .severity(AlertSeverity::Info), + ) + .when(!self.update_alert_dismissed, |section| { + section.child( + Alert::new("gallery-alert-sync") + .title("Sync failed") + .message("Will retry when you're back online.") + .severity(AlertSeverity::Danger) + .on_dismiss({ + let gallery = gallery.clone(); + move |cx| { + gallery.update(cx, |gallery, cx| { + gallery.update_alert_dismissed = true; + cx.notify(); + }); + } + }), + ) + }), + ), + ) + .child( + v_flex() + .gap_2() + .child(section_label( + "alert stack · pushed, persistent — macOS notification stack", + colors, + )) + .child( + div().flex().gap_2().children([ + { + let alerts = self.alerts.clone(); + Button::new("gallery-alert-push-info") + .label("Push info") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| { + alerts.update(cx, |alerts, cx| { + alerts.push( + Alert::new("pushed-info") + .title("Backed up") + .message("Everything is in sync.") + .severity(AlertSeverity::Info), + cx, + ); + }); + }) + }, + { + let alerts = self.alerts.clone(); + Button::new("gallery-alert-push-warning") + .label("Push warning") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| { + alerts.update(cx, |alerts, cx| { + alerts.push( + Alert::new("pushed-warning") + .title("Storage almost full") + .message("1.2 GB remaining on this device.") + .severity(AlertSeverity::Warning), + cx, + ); + }); + }) + }, + { + let alerts = self.alerts.clone(); + Button::new("gallery-alert-clear") + .label("Clear all") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| { + alerts.update(cx, |alerts, cx| alerts.clear(cx)); + }) + }, + ]), + ), + ) + .child( + v_flex() + .gap_2() + .child(section_label( + "toast · temporary message, one at a time — Adwaita Toast, macOS banner", colors, )) .child( div().flex().gap_2().children([ { let toasts = self.toasts.clone(); - Button::new("gallery-toast-info") - .label("Info") + Button::new("gallery-toast-undo") + .label("Deleted (with undo)") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { toasts.update(cx, |toasts, cx| { toasts.push( - Toast::new("New version available") - .message("Reload to update to 2.4.0."), + Toast::new("Draft deleted") + .action(ToastAction::new("Undo", |cx| { + // A real app restores the draft here. + let _ = cx; + })), cx, ); }); @@ -572,16 +667,14 @@ impl Render for Gallery { }, { let toasts = self.toasts.clone(); - Button::new("gallery-toast-success") - .label("Success") + Button::new("gallery-toast-saved") + .label("Saved (with icon)") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { toasts.update(cx, |toasts, cx| { toasts.push( Toast::new("Workspace saved") - .message("All changes written to disk.") - .severity(ToastSeverity::Success) - .no_icon(), + .icon(IconRole::Check), cx, ); }); @@ -589,31 +682,14 @@ impl Render for Gallery { }, { let toasts = self.toasts.clone(); - Button::new("gallery-toast-warning") - .label("Warning") + Button::new("gallery-toast-connection") + .label("Connection lost (sticky)") .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { toasts.update(cx, |toasts, cx| { toasts.push( - Toast::new("Disk almost full") - .message("1.2 GB remaining.") - .severity(ToastSeverity::Warning), - cx, - ); - }); - }) - }, - { - let toasts = self.toasts.clone(); - Button::new("gallery-toast-danger") - .label("Danger (sticky)") - .variant(ButtonVariant::Destructive) - .on_click(move |_, _, cx| { - toasts.update(cx, |toasts, cx| { - toasts.push( - Toast::new("Sync failed") - .message("Will retry when online.") - .severity(ToastSeverity::Danger) + Toast::new("Connection lost") + .icon(IconRole::Warning) .duration(Duration::ZERO), cx, ); @@ -1081,6 +1157,7 @@ impl Render for Gallery { ) .child(dialog) .child(alert) + .child(AlertsView::new("gallery-alerts", self.alerts.clone())) .child(ToastsView::new("gallery-toasts", self.toasts.clone())) } } @@ -1162,9 +1239,12 @@ fn main() { }) .detach(); let toasts = cx.new(|_| Toasts::default()); + let alerts = cx.new(|_| Alerts::default()); 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 { button_clicks: 0, checkbox_checked: false, @@ -1177,6 +1257,8 @@ fn main() { menu_open: false, dialog_open: false, alert_open: false, + update_alert_dismissed: false, + alerts, toasts, scroll: gpui::ScrollHandle::new(), list_selected: None,