From 64f4c3ff0ce60859d7c7975ba41301e5d6c01689 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 30 Oct 2026 01:31:16 +0700 Subject: [PATCH] add toast --- crates/core/src/button.rs | 6 +- crates/core/src/lib.rs | 3 + crates/core/src/menu.rs | 8 +- crates/core/src/toast.rs | 302 +++++++++++++++++++++++++++++++ crates/core/tests/toast.rs | 89 +++++++++ crates/icons/src/lib.rs | 26 +-- crates/theme/src/colors.rs | 12 ++ crates/theme/src/definition.rs | 20 ++ crates/theme/src/resolve.rs | 26 +++ crates/theme/src/source/macos.rs | 2 + crates/theme/src/system.rs | 3 + docs/PLAN.md | 4 +- examples/gallery/src/main.rs | 101 +++++++++-- 13 files changed, 558 insertions(+), 44 deletions(-) create mode 100644 crates/core/src/toast.rs create mode 100644 crates/core/tests/toast.rs diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index c43e84d..3c792e7 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -179,11 +179,7 @@ impl RenderOnce for Button { .text_color(foreground) .when_some(a11y_label, |button, label| button.aria_label(label)) .when_some(self.icon, |button, role| { - button.child( - icon(role) - .size(f32::from(metrics.icon_size)) - .color(foreground), - ) + button.child(icon(role).size(metrics.icon_size).color(foreground)) }) .when_some(self.label, |button, label| button.child(label)) .when(self.disabled, |button| button.opacity(0.5)) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 72f86f0..1e5cf7c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -48,6 +48,9 @@ pub mod popover; #[cfg(feature = "scroll_area")] pub mod scroll_area; +#[cfg(feature = "toast")] +pub mod toast; + #[cfg(feature = "tooltip")] pub mod tooltip; diff --git a/crates/core/src/menu.rs b/crates/core/src/menu.rs index e346eff..1464e5e 100644 --- a/crates/core/src/menu.rs +++ b/crates/core/src/menu.rs @@ -495,17 +495,13 @@ fn render_item( row.child(div().flex_none().when(checked, |slot| { slot.child( icon_glyph(IconRole::Check) - .size(f32::from(chrome.icon_size) * 0.75) + .size(chrome.icon_size * 0.75) .color(label_color), ) })) }) .when_some(icon_role, |row, role| { - row.child( - icon_glyph(role) - .size(f32::from(chrome.icon_size)) - .color(label_color), - ) + row.child(icon_glyph(role).size(chrome.icon_size).color(label_color)) }) .child( div() diff --git a/crates/core/src/toast.rs b/crates/core/src/toast.rs new file mode 100644 index 0000000..a01ae38 --- /dev/null +++ b/crates/core/src/toast.rs @@ -0,0 +1,302 @@ +use std::rc::Rc; +use std::time::Duration; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, Context, ElementId, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, Window, deferred, div, + px, +}; +use gpui_native_icons::{IconRole, icon}; +use gpui_native_theme::ActiveTheme as _; + +use crate::common::v_flex; + +/// The dismiss handler of a [`Toast`]. +pub type DismissHandler = 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, +} + +/// Toast notification. +#[derive(Clone)] +pub struct Toast { + title: SharedString, + message: Option, + severity: ToastSeverity, + /// The painted glyph; `None` derives it from the severity. + icon: Option, + show_icon: bool, + duration: Option, + on_dismiss: Option, +} + +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 + where + L: Into, + { + Self { + title: title.into(), + message: None, + severity: ToastSeverity::Info, + icon: None, + show_icon: true, + 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. + 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 + } + + /// Sets the time before the toast dismisses itself. + pub fn duration(mut self, duration: Duration) -> Self { + self.duration = Some(duration); + self + } + + /// Runs when the toast is dismissed by any means. + pub fn on_dismiss(mut self, handler: F) -> Self + where + F: Fn(&mut App) + 'static, + { + self.on_dismiss = Some(Rc::new(handler)); + self + } +} + +/// One raised toast, with its identity in the store. +#[derive(Clone)] +pub struct ToastEntry { + pub id: usize, + pub toast: Toast, +} + +/// The store of raised toasts. +#[derive(Default)] +pub struct Toasts { + entries: Vec, + next_id: usize, +} + +impl Toasts { + /// Raises a toast, starting its auto-dismiss timer. 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 }); + cx.notify(); + + if let Some(duration) = duration.filter(|duration| !duration.is_zero()) { + cx.spawn(async move |this, cx| { + cx.background_executor().timer(duration).await; + this.update(cx, |toasts, cx| toasts.dismiss(id, cx)).ok(); + }) + .detach(); + } + + id + } + + /// Dismisses one toast 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.toast.on_dismiss { + on_dismiss(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)] +pub struct ToastsView { + toasts: Entity, + id: ElementId, +} + +impl ToastsView { + /// Binds the view to a toast store. + pub fn new(id: I, toasts: Entity) -> Self + where + I: Into, + { + Self { + id: id.into(), + toasts, + } + } +} + +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 entries: Vec = self.toasts.read(cx).entries().to_vec(); + + deferred( + div() + .id(self.id) + .absolute() + .top_4() + .right_4() + .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.primary.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); + }); + }), + ) + })), + ) + } +} diff --git a/crates/core/tests/toast.rs b/crates/core/tests/toast.rs new file mode 100644 index 0000000..7585bcc --- /dev/null +++ b/crates/core/tests/toast.rs @@ -0,0 +1,89 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gpui::{AppContext as _, TestAppContext}; +use gpui_native_core::toast::{Toast, ToastSeverity, Toasts}; +use gpui_native_theme::Theme; + +fn toasts_store() -> (TestAppContext, gpui::Entity) { + let cx = TestAppContext::single(); + cx.update(Theme::init); + let toasts = cx.update(|cx| cx.new(|_| Toasts::default())); + (cx, toasts) +} + +#[test] +fn push_raises_a_toast_and_the_timer_dismisses_it() { + let (cx, toasts) = toasts_store(); + + let id = cx.update(|cx| { + toasts.update(cx, |toasts, cx| { + toasts.push(Toast::new("Saved").severity(ToastSeverity::Success), cx) + }) + }); + assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1); + + // The default lifetime is five seconds. + cx.executor().advance_clock(Toast::DURATION); + cx.run_until_parked(); + assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0); + let _ = id; +} + +#[test] +fn sticky_toasts_ignore_the_timer_until_dismissed() { + let (cx, toasts) = toasts_store(); + + let id = cx.update(|cx| { + toasts.update(cx, |toasts, cx| { + toasts.push(Toast::new("Sticky").duration(Duration::ZERO), cx) + }) + }); + + cx.executor().advance_clock(Toast::DURATION * 4); + cx.run_until_parked(); + assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1); + + cx.update(|cx| { + toasts.update(cx, |toasts, cx| toasts.dismiss(id, cx)); + }); + assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0); +} + +#[test] +fn dismiss_runs_the_handler_and_clear_removes_everything() { + let (cx, toasts) = toasts_store(); + let dismissed = Rc::new(std::cell::RefCell::new(Vec::<&'static str>::new())); + + let ids = cx.update(|cx| { + 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, + ), + ] + }) + }); + + cx.update(|cx| { + toasts.update(cx, |toasts, cx| toasts.dismiss(ids[0], cx)); + }); + assert_eq!(*dismissed.borrow(), vec!["first"]); + + cx.update(|cx| { + toasts.update(cx, |toasts, cx| toasts.clear(cx)); + }); + assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0); + + fn record(sink: &Rc>>, what: &'static str) { + sink.borrow_mut().push(what); + } +} diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs index a192bf5..2ee40a4 100644 --- a/crates/icons/src/lib.rs +++ b/crates/icons/src/lib.rs @@ -7,12 +7,11 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - AssetSource, Hsla, IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, - Window, div, px, svg, + AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, + Styled as _, Window, div, px, svg, }; -/// The semantic glyphs UI components need. Grows one role at a time as -/// components call for it. +/// The semantic glyphs UI components need. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IconRole { Check, @@ -65,12 +64,11 @@ impl IconRole { } } -/// An icon element. Unpainted unless a color is set (gpui paints SVGs from -/// the element's text color only). +/// An icon element. Unpainted unless a color is set. #[derive(IntoElement)] pub struct Icon { role: IconRole, - size: f32, + size: Pixels, color: Option, } @@ -78,13 +76,13 @@ pub struct Icon { pub fn icon(role: IconRole) -> Icon { Icon { role, - size: 16.0, + size: px(16.0), color: None, } } impl Icon { - pub fn size(mut self, size: f32) -> Self { + pub fn size(mut self, size: Pixels) -> Self { self.size = size; self } @@ -97,7 +95,7 @@ impl Icon { impl RenderOnce for Icon { fn render(self, _window: &mut Window, _cx: &mut gpui::App) -> impl IntoElement { - div().flex_none().size(px(self.size)).child( + div().flex_none().size(self.size).child( svg() .size_full() .path(self.role.asset_path()) @@ -106,8 +104,7 @@ impl RenderOnce for Icon { } } -/// The asset source serving this crate's bundled glyphs. Register it on the -/// application: `application().with_assets(IconAssets)`. +/// The asset source serving this crate's bundled glyphs. pub struct IconAssets; fn asset_bytes(path: &str) -> Option<&'static [u8]> { @@ -158,9 +155,4 @@ mod tests { ); } } - - #[test] - fn unknown_assets_report_none() { - assert_eq!(asset_bytes("icons/nope.svg"), None); - } } diff --git a/crates/theme/src/colors.rs b/crates/theme/src/colors.rs index af186cf..1f1b1ec 100644 --- a/crates/theme/src/colors.rs +++ b/crates/theme/src/colors.rs @@ -29,6 +29,10 @@ pub struct ColorTokens { pub accent_foreground: Hsla, pub destructive: Hsla, pub destructive_foreground: Hsla, + pub success: Hsla, + pub success_foreground: Hsla, + pub warning: Hsla, + pub warning_foreground: Hsla, pub border: Hsla, pub input: Hsla, pub ring: Hsla, @@ -57,6 +61,10 @@ impl ColorTokens { accent_foreground: hex(0xffffff), destructive: hex(0xdc2626), destructive_foreground: hex(0xffffff), + success: hex(0x16a34a), + success_foreground: hex(0xffffff), + warning: hex(0xd97706), + warning_foreground: hex(0xffffff), border: hex(0xe4e4e7), input: hex(0xe4e4e7), ring: hex(0x93c5fd), @@ -84,6 +92,10 @@ impl ColorTokens { accent_foreground: hex(0xffffff), destructive: hex(0xef4444), destructive_foreground: hex(0xffffff), + success: hex(0x22c55e), + success_foreground: hex(0x052e16), + warning: hex(0xf59e0b), + warning_foreground: hex(0x451a03), border: hex(0x27272a), input: hex(0x27272a), ring: hex(0x1d4ed8), diff --git a/crates/theme/src/definition.rs b/crates/theme/src/definition.rs index bddb5f3..c854a1e 100644 --- a/crates/theme/src/definition.rs +++ b/crates/theme/src/definition.rs @@ -25,6 +25,10 @@ pub struct ThemeDefinition { pub muted_foreground: Option, pub destructive: Option, pub destructive_foreground: Option, + pub success: Option, + pub success_foreground: Option, + pub warning: Option, + pub warning_foreground: Option, pub border: Option, pub input: Option, pub ring: Option, @@ -94,6 +98,22 @@ impl ThemeDefinition { parse_hex(self.destructive_foreground.as_deref()?) } + pub fn success(&self) -> Option { + parse_hex(self.success.as_deref()?) + } + + pub fn success_foreground(&self) -> Option { + parse_hex(self.success_foreground.as_deref()?) + } + + pub fn warning(&self) -> Option { + parse_hex(self.warning.as_deref()?) + } + + pub fn warning_foreground(&self) -> Option { + parse_hex(self.warning_foreground.as_deref()?) + } + pub fn border(&self) -> Option { parse_hex(self.border.as_deref()?) } diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 5bb31e4..b41bce8 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -10,6 +10,8 @@ pub struct Ramps { pub primary: AccentShades, pub secondary: AccentShades, pub destructive: AccentShades, + pub success: AccentShades, + pub warning: AccentShades, } /// Everything a component paints from, after the overlay chain. @@ -58,6 +60,16 @@ pub(crate) fn resolve( system_palette.destructive, base.destructive, ); + let success = pick( + def_color(|d| d.success()), + system_palette.success, + base.success, + ); + let warning = pick( + def_color(|d| d.warning()), + system_palette.warning, + base.warning, + ); let mut colors = ColorTokens { background: pick( @@ -114,6 +126,18 @@ pub(crate) fn resolve( None, contrast_foreground(destructive), ), + success: pick(def_color(|d| d.success()), None, base.success), + success_foreground: pick( + def_color(|d| d.success_foreground()), + None, + contrast_foreground(success), + ), + warning: pick(def_color(|d| d.warning()), None, base.warning), + warning_foreground: pick( + def_color(|d| d.warning_foreground()), + None, + contrast_foreground(warning), + ), border: pick( def_color(|d| d.border()), system_palette.border, @@ -182,6 +206,8 @@ pub(crate) fn resolve( colors.background, variant == ThemeVariant::Dark, ), + success: derive_shades(success, colors.background, variant == ThemeVariant::Dark), + warning: derive_shades(warning, colors.background, variant == ThemeVariant::Dark), }, colors, radius, diff --git a/crates/theme/src/source/macos.rs b/crates/theme/src/source/macos.rs index e3ac797..42e5ef1 100644 --- a/crates/theme/src/source/macos.rs +++ b/crates/theme/src/source/macos.rs @@ -81,6 +81,8 @@ impl MacosSource { muted: None, muted_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()), destructive: Self::color_to_hsla(&NSColor::systemRedColor()), + success: None, + warning: None, border: Self::color_to_hsla(&NSColor::separatorColor()), input: Self::color_to_hsla(&NSColor::controlBackgroundColor()), ring: Self::color_to_hsla(&NSColor::keyboardFocusIndicatorColor()), diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index 54dd85c..3b8a2d6 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -43,6 +43,9 @@ pub struct PartialPalette { pub muted: Option, pub muted_foreground: Option, pub destructive: Option, + /// No platform states severity colors; presets override them. + pub success: Option, + pub warning: Option, pub border: Option, pub input: Option, pub ring: Option, diff --git a/docs/PLAN.md b/docs/PLAN.md index a7c44ff..0c45acd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -152,7 +152,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** — with the first severity-bearing component (Toast, badges, validation); the Omarchy mapping above already anticipates them. +- ~~**`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. - **`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. Controls through unit 10 paint from existing roles and ramps. @@ -212,7 +212,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 | | 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 | 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. Sheet dropped from the roadmap. Toast lands `success`/`warning` tokens when it comes | +| 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 | | then | data (new) | List, Table, Tree, Tabs, Sidebar | Created on demand, same gate. | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | | editor | editor (new) | Text/code editor, syntax highlighting (D12) | Same gate. | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index c97644a..4fa7bfa 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use gpui::prelude::FluentBuilder as _; use gpui::{ App, AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, @@ -18,6 +20,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::toggle::Toggle; use gpui_native_core::tooltip::Tooltip; use gpui_native_icons::{IconAssets, IconRole, icon}; @@ -36,6 +39,7 @@ struct Gallery { menu_open: bool, dialog_open: bool, alert_open: bool, + toasts: Entity, scroll: gpui::ScrollHandle, } @@ -335,6 +339,68 @@ impl Render for Gallery { }) }), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "toast · native ref — NSUserNotification stack, WinUI InfoBar, Adwaita in-app notifications", + colors, + )) + .child( + div().flex().gap_2().children([ + { + let toasts = self.toasts.clone(); + Button::new("gallery-toast-success") + .label("Success") + .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(), + cx, + ); + }); + }) + }, + { + let toasts = self.toasts.clone(); + Button::new("gallery-toast-warning") + .label("Warning") + .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) + .duration(Duration::ZERO), + cx, + ); + }); + }) + }, + ]), + ), + ) .child( v_flex() .gap_2() @@ -673,7 +739,7 @@ impl Render for Gallery { div().flex().items_center().gap_4().children( IconRole::all() .iter() - .map(|role| icon(*role).size(20.0).color(colors.foreground)), + .map(|role| icon(*role).size(px(20.)).color(colors.foreground)), ), ), ); @@ -793,6 +859,7 @@ impl Render for Gallery { ) .child(dialog) .child(alert) + .child(ToastsView::new("gallery-toasts", self.toasts.clone())) } } @@ -872,19 +939,25 @@ fn main() { cx.refresh_windows(); }) .detach(); - cx.new(|_| Gallery { - button_clicks: 0, - checkbox_checked: false, - switch_checked: true, - plan: SharedString::from("starter"), - toggle_pressed: false, - volume: 40.0, - input_state, - popover_open: false, - menu_open: false, - dialog_open: false, - alert_open: false, - scroll: gpui::ScrollHandle::new(), + let toasts = cx.new(|_| Toasts::default()); + cx.new(|cx| { + // Toast pushes and dismissals repaint the stack. + cx.observe(&toasts, |_, _, cx| cx.notify()).detach(); + Gallery { + button_clicks: 0, + checkbox_checked: false, + switch_checked: true, + plan: SharedString::from("starter"), + toggle_pressed: false, + volume: 40.0, + input_state, + popover_open: false, + menu_open: false, + dialog_open: false, + alert_open: false, + toasts, + scroll: gpui::ScrollHandle::new(), + } }) }, ) {