Skip to content

Guide Notification

MeowLynxSea edited this page Jun 13, 2026 · 4 revisions

NotificationCenter

NotificationCenter is the global toast / notification manager. It owns the queue, schedules auto-dismiss timers, and exposes items() for the renderer to paint. The headless layer lives in crates/yororen-ui-core/src/notification/center.rs; the visual host is a caller-rendered widget that you wire into the render tree.

This page covers the global + the builder, the deferred placement above modals, and the sticky / manual-dismiss semantics.

When to use it

  • You need to show toasts from anywhere in your app.
  • You want automatic queue management (max 5 by default).
  • You want one of:
    • Auto-dismiss after a configurable duration (default 4 s).
    • Manual-dismiss (sticky) for important alerts.
    • Persistence across process restarts for sticky items.
  • You want click / dismiss callbacks attached to a specific notification.

If you only need a single in-page message (e.g. an inline error under a form field), use Toast instead — that's a local visual component, not a global queue.

Architecture

Three pieces:

  1. NotificationCentergpui::Global thin state machine. Owns the queue, schedules auto-dismiss timers, exposes items() for the renderer, and persists sticky items.
  2. Toast host — caller-rendered scaffold that reads center.items() and paints the stack. The gallery_demo's notifications_host.rs is the canonical implementation; copy it.
  3. Notification — the payload: message, optional title, ToastKind, dismiss strategy, optional sticky flag, optional payload JSON, optional action label.

1) Set the global

Call once at boot, after the renderer is installed but before opening any window that may show toasts:

use yororen_ui::notification::center::NotificationCenter;

cx.set_global(NotificationCenter::new());

The center is a gpui::Global (cheap to clone — it's an Arc<Mutex<…>> underneath), so you can grab it from any event handler:

let center = cx.global::<NotificationCenter>().clone();

2) Push a notification

use yororen_ui::notification::center::{Notification, ToastKind};

center.notify(
    Notification::new("Saved!")
        .title("Done")
        .kind(ToastKind::Success),
    cx,
);

ToastKind is one of Neutral | Success | Warning | Error | Info. The renderer maps each kind to the status.<kind>.bg / .fg colors in your theme.

notify returns the NotificationId if you need to dismiss a specific toast later:

let id = center.notify(Notification::new("Hello"), cx);
center.dismiss(id, cx);  // remove it from the queue

Builder methods (on Notification)

Method What it does
Notification::new(message) constructor
.title("Done") optional bold title
.kind(ToastKind::Success) Neutral (default) / Success / Warning / Error / Info
.dismiss(DismissStrategy::After { duration_ms: 4000 }) override the default 4-second timer
.dismiss(DismissStrategy::Manual) never auto-dismiss
.sticky(true) upgrade dismiss to Manual and mark for cross-restart persistence
.action_label("View") label for a click action
.payload(json!({…})) arbitrary JSON for the click callback

DismissStrategy

pub enum DismissStrategy {
    /// Never auto-dismiss. User must explicitly close.
    Manual,
    /// Auto-dismiss after `duration_ms`.
    After { duration_ms: u64 },
}

impl Default for DismissStrategy {
    fn default() -> Self { Self::After { duration_ms: 4000 } }
}

3) Wire the toast host into the render tree

The bundled default-renderer ships a notification_host.rs file, but it depends on a renderer-internal module that does not exist in the current API — it is dead code. Implementing the host in your app (or copying the one from gallery_demo) is the intended path.

The host reads center.items() and renders a stack of cards floating to the top-right of the window. The shape (paraphrasing gallery_demo/src/notifications_host.rs):

use gpui::{
    Context, Hsla, IntoElement, ParentElement, Render, Styled, Window,
    deferred, div, hsla, px,
};
use yororen_ui::notification::center::{
    Notification, NotificationCenter, NotificationId, ToastKind,
};
use yororen_ui::theme::ActiveTheme;

/// Paint priority — 3 sits above modal scrim (2) and popovers (1).
const HOST_PRIORITY: usize = 3;

pub fn render_host(cx: &mut Context<MyApp>) -> impl IntoElement {
    let mut stack = div()
        .id("ui:notification-host")
        .absolute()
        .top_0()
        .right_0()
        .mt(px(16.))
        .mr(px(16.))
        .flex()
        .flex_col()
        .gap(px(8.))
        .items_end();

    let Some(center) = cx.try_global::<NotificationCenter>() else {
        return stack;
    };
    for n in center.items() {
        stack = stack.child(toast_card(cx, n));
    }
    stack
}

fn toast_card(cx: &mut Context<MyApp>, n: Notification) -> gpui::Stateful<gpui::Div> {
    let kind_path = match n.kind {
        ToastKind::Success => "status.success",
        ToastKind::Warning => "status.warning",
        ToastKind::Error   => "status.error",
        ToastKind::Info    => "status.info",
        ToastKind::Neutral => "status.neutral",
    };
    let bg = cx.theme().get_color(&format!("{kind_path}.bg"))
        .unwrap_or_else(|| cx.theme().get_color("surface.raised").unwrap_or_else(default_surface));
    let fg = cx.theme().get_color(&format!("{kind_path}.fg"))
        .unwrap_or_else(|| cx.theme().get_color("content.primary").unwrap_or_else(default_text));

    let id = n.id;
    let center = cx.try_global::<NotificationCenter>().cloned()
        .unwrap_or_else(NotificationCenter::new);

    let close = div()
        .id(("ui:notification:dismiss", id.raw()))
        .w(px(20.)).h(px(20.))
        .flex().items_center().justify_center()
        .cursor_pointer()
        .text_color(fg).opacity(0.55)
        .on_click(move |_ev, _w, cx| center.dismiss(id, cx))
        .child("×");

    let title_row = div().flex().flex_row().items_start().justify_between().gap(px(8.))
        .child(
            div().flex_1().flex().flex_col().gap(px(2.))
                .when_some(n.title.clone(), |d, t|
                    d.child(div().text_sm().font_weight(gpui::FontWeight::BOLD).text_color(fg).child(t)))
                .child(div().text_sm().text_color(fg).child(n.message.clone()))
        )
        .child(close);

    div()
        .id(("ui:notification", id.raw()))
        .w(px(320.)).p(px(12.)).rounded(px(6.))
        .border_1().border_color(bg)
        .bg(bg)
        .child(title_row)
}

fn default_surface() -> Hsla { hsla(0.0, 0.0, 0.95, 1.0) }
fn default_text()    -> Hsla { hsla(0.0, 0.0, 0.10, 1.0) }

Then add it as a deferred child of your root:

impl Render for MyApp {
    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .size_full()
            .child(/* … page content … */)
            // Modals are siblings, painted at priority 2.
            // .child(deferred(modal_el).with_priority(2))
            // Toast host is the topmost overlay — priority 3.
            .child(deferred(render_host(cx)).with_priority(HOST_PRIORITY))
    }
}

Paint order

Priority 3 keeps toasts above the modal scrim (priority 2) and popover / dropdown panels (priority 1). Bump the priority if a future overlay needs to sit above notifications.

root
├── page content
├── modal scrim + panel            (deferred priority 2)
├── popover / dropdown / menu      (deferred priority 1, owned by renderer)
└── notification host              (deferred priority 3)

Auto-dismiss timer

The center refuses to schedule the auto-dismiss timer unless register_host_window has been called. Your host's Render::render should call center.register_host_window(window.window_handle()) on every paint (the gallery_demo host does this). Without that, non-sticky toasts never auto-dismiss.

impl Render for MyApp {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        if let Some(center) = cx.try_global::<NotificationCenter>() {
            center.register_host_window(window.window_handle());
        }
        // … render …
    }
}

Sticky semantics

Notification::sticky(true) upgrades dismiss to Manual automatically (both in the builder and in the scheduler, dual-guarded). Use it for:

  • Error alerts that need acknowledgment.
  • Long-running-task notifications ("Indexing 1.2M files…") that should stay until the user dismisses them.
  • Notifications you want to persist across process restarts — sticky items are written to the persisted snapshot and reloaded on next launch.
// Sticky error — must be dismissed manually, persists across restart.
center.notify(
    Notification::new("Build complete — 12 errors")
        .title("Build failed")
        .kind(ToastKind::Error)
        .sticky(true),    // also sets dismiss to Manual
    cx,
);

// Manual-dismiss without persistence (rare).
center.notify(
    Notification::new("Welcome!")
        .kind(ToastKind::Info)
        .dismiss(DismissStrategy::Manual),
    cx,
);

The scheduler is defensive: a hand-built Notification with sticky: true and dismiss: After { … } will still not auto-dismiss — sticky always wins.

Callbacks

For click / dismiss callbacks, use notify_with_callbacks:

use std::sync::Arc;
use yororen_ui::notification::center::Notification;

center.notify_with_callbacks(
    Notification::new("Click to view details")
        .kind(ToastKind::Info)
        .action_label("View")
        .payload(serde_json::json!({ "id": 42 })),
    Some(Arc::new(|n, _ev, _w, cx| {
        // n.payload contains the JSON
        println!("clicked: {:?}", n.payload);
    })),
    Some(Arc::new(|_n, _w, cx| {
        // fired on explicit dismiss (auto or manual)
    })),
    cx,
);

Callbacks are not persisted across restarts. They're re-attached each launch by re-emitting notify_with_callbacks for each loaded sticky notification.

Configuration

let center = cx.global::<NotificationCenter>().clone();

// Queue length (default 5; oldest is dropped when over capacity).
center.set_max_queue_len(10);

// Persistence (default on, key "yororen_ui:notifications").
center.set_persistence(false, "my-app-key");

NotificationCenter API summary

Method What it does
NotificationCenter::new() constructor
.notify(notification, cx) push a notification, returns NotificationId
.notify_with_callbacks(n, on_click, on_dismiss, cx) push with callbacks
.dismiss(id, cx) dismiss a specific notification
.clear(cx) dismiss all
.items() snapshot the queue (used by the host)
.set_max_queue_len(n) set the queue cap
.set_persistence(enabled, key) toggle persistence
.register_host_window(window) required for auto-dismiss to fire

NotificationId

pub struct NotificationId(u64);

impl NotificationId {
    pub fn allocate() -> Self;     // process-local monotonic counter
    pub fn raw(self) -> u64;       // useful when building a gpui::ElementId
}

Stable, opaque, monotonically-increasing. Used to disambiguate stacked toasts in the gpui element tree and as the key in the center's HashMap<NotificationId, ClickCb> / DismissCb.

See also

  • Guide-Quick-Start §5 — installing the global at boot
  • Guide-Composing-UI §5 — the popover-with-menu pattern that often pairs with toasts
  • Demosgallery_demo/src/notifications_host.rs is the canonical host implementation
  • codex-skills/yororen-ui-app-core §6 — install + notify + sticky

Clone this wiki locally