Skip to main content

gpui_component/
notification.rs

1use std::{any::TypeId, borrow::Cow, collections::HashMap, rc::Rc, time::Duration};
2
3use gpui::{
4    Anchor, Animation, AnimationExt, AnyElement, AnyWindowHandle, App, AppContext, ClickEvent,
5    Context, DismissEvent, ElementId, Entity, EventEmitter, FocusHandle, Global,
6    InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, SharedString,
7    StatefulInteractiveElement, StyleRefinement, Styled, Subscription, SystemNotification,
8    SystemNotificationResponse, WeakEntity, Window, WindowId, div, prelude::FluentBuilder, px,
9};
10use gpui_base::{
11    Toast as BaseToast, ToastManager, ToastMotion, ToastOptions, ToastStack, ToastStackState,
12    ToastTransitionStatus,
13};
14
15use crate::{
16    ActiveTheme as _, Edges, Icon, IconName, Sizable as _, StyledExt, TITLE_BAR_HEIGHT,
17    animation::cubic_bezier,
18    button::{Button, ButtonVariants as _},
19    styled::toast_shadow,
20    v_flex,
21};
22
23const NOTIFICATION_TRANSITION_DURATION: Duration = Duration::from_millis(400);
24const NOTIFICATION_EXIT_DURATION: Duration = Duration::from_millis(200);
25const NOTIFICATION_TRANSITION_OFFSET: Pixels = px(96.);
26const DEFAULT_NOTIFICATION_WIDTH: Pixels = px(382.);
27const NOTIFICATION_ADVANCE_INTERVAL: Duration = Duration::from_millis(50);
28struct DismissRequest;
29
30#[derive(Debug, Clone, Copy, Default)]
31pub enum NotificationType {
32    #[default]
33    Info,
34    Success,
35    Warning,
36    Error,
37}
38
39impl NotificationType {
40    fn icon(&self, cx: &App) -> Icon {
41        match self {
42            Self::Info => Icon::new(IconName::Info).text_color(cx.theme().info),
43            Self::Success => Icon::new(IconName::CircleCheck).text_color(cx.theme().success),
44            Self::Warning => Icon::new(IconName::TriangleAlert).text_color(cx.theme().warning),
45            Self::Error => Icon::new(IconName::CircleX).text_color(cx.theme().danger),
46        }
47    }
48}
49
50/// Where a notification is presented: as an in-app toast, in the operating
51/// system's notification center, or both.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum NotificationDelivery {
54    /// Show only the in-app toast.
55    #[default]
56    InApp,
57    /// Post only to the OS notification center; no in-app toast is shown.
58    System,
59    /// Show the in-app toast and post to the OS notification center.
60    InAppAndSystem,
61}
62
63impl NotificationDelivery {
64    /// Whether this delivery shows an in-app toast.
65    pub fn includes_in_app(&self) -> bool {
66        matches!(self, Self::InApp | Self::InAppAndSystem)
67    }
68
69    /// Whether this delivery posts to the OS notification center.
70    pub fn includes_system(&self) -> bool {
71        matches!(self, Self::System | Self::InAppAndSystem)
72    }
73}
74
75/// Namespaces the library's system-notification tags away from tags the
76/// application posts itself, so the response handler can tell them apart.
77const SYSTEM_TAG_PREFIX: &str = "gpui-component/notification/";
78
79#[derive(Debug, PartialEq, Clone, Hash, Eq)]
80pub(crate) enum NotificationId {
81    Id(TypeId),
82    IdAndElementId(TypeId, ElementId),
83}
84
85impl NotificationId {
86    /// Stable OS-notification tag for this id within a running binary, so a
87    /// repeat push with the same id replaces the previous system
88    /// notification, mirroring the in-app replace semantics.
89    fn system_tag(&self) -> SharedString {
90        format!("{SYSTEM_TAG_PREFIX}{self:?}").into()
91    }
92}
93
94impl From<TypeId> for NotificationId {
95    fn from(type_id: TypeId) -> Self {
96        Self::Id(type_id)
97    }
98}
99
100impl From<(TypeId, ElementId)> for NotificationId {
101    fn from((type_id, id): (TypeId, ElementId)) -> Self {
102        Self::IdAndElementId(type_id, id)
103    }
104}
105
106/// A notification element.
107pub struct Notification {
108    /// The id is used make the notification unique.
109    /// Then you push a notification with the same id, the previous notification will be replaced.
110    ///
111    /// None means the notification will be added to the end of the list.
112    id: NotificationId,
113    style: StyleRefinement,
114    type_: Option<NotificationType>,
115    title: Option<SharedString>,
116    message: Option<SharedString>,
117    icon: Option<Icon>,
118    placement: Option<Anchor>,
119    delivery: Option<NotificationDelivery>,
120    autohide: bool,
121    action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
122    content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
123    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
124    on_close: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
125    transition_status: ToastTransitionStatus,
126}
127
128impl From<String> for Notification {
129    fn from(s: String) -> Self {
130        Self::new().message(s)
131    }
132}
133
134impl From<SharedString> for Notification {
135    fn from(s: SharedString) -> Self {
136        Self::new().message(s)
137    }
138}
139
140impl From<&str> for Notification {
141    fn from(s: &str) -> Self {
142        Self::new().message(s)
143    }
144}
145
146impl<'a> From<Cow<'a, str>> for Notification {
147    fn from(s: Cow<'a, str>) -> Self {
148        Self::new().message(s)
149    }
150}
151
152impl<T> From<(NotificationType, T)> for Notification
153where
154    T: Into<SharedString>,
155{
156    fn from((type_, content): (NotificationType, T)) -> Self {
157        Self::new().message(content).with_type(type_)
158    }
159}
160
161struct DefaultIdType;
162
163impl Notification {
164    /// Create a new notification.
165    ///
166    /// The default id is a random UUID.
167    pub fn new() -> Self {
168        let id: SharedString = uuid::Uuid::new_v4().to_string().into();
169        let id = (TypeId::of::<DefaultIdType>(), id.into());
170
171        Self {
172            id: id.into(),
173            style: StyleRefinement::default(),
174            title: None,
175            message: None,
176            type_: None,
177            icon: None,
178            placement: None,
179            delivery: None,
180            autohide: true,
181            action_builder: None,
182            content_builder: None,
183            on_click: None,
184            on_close: None,
185            transition_status: ToastTransitionStatus::Starting,
186        }
187    }
188
189    /// Set the message of the notification, default is None.
190    pub fn message(mut self, message: impl Into<SharedString>) -> Self {
191        self.message = Some(message.into());
192        self
193    }
194
195    /// Create an info notification with the given message.
196    pub fn info(message: impl Into<SharedString>) -> Self {
197        Self::new()
198            .message(message)
199            .with_type(NotificationType::Info)
200    }
201
202    /// Create a success notification with the given message.
203    pub fn success(message: impl Into<SharedString>) -> Self {
204        Self::new()
205            .message(message)
206            .with_type(NotificationType::Success)
207    }
208
209    /// Create a warning notification with the given message.
210    pub fn warning(message: impl Into<SharedString>) -> Self {
211        Self::new()
212            .message(message)
213            .with_type(NotificationType::Warning)
214    }
215
216    /// Create an error notification with the given message.
217    pub fn error(message: impl Into<SharedString>) -> Self {
218        Self::new()
219            .message(message)
220            .with_type(NotificationType::Error)
221    }
222
223    /// Set the type for unique identification of the notification.
224    ///
225    /// ```rs
226    /// struct MyNotificationKind;
227    /// let notification = Notification::new().message("Hello").id::<MyNotificationKind>();
228    /// ```
229    pub fn id<T: Sized + 'static>(mut self) -> Self {
230        self.id = TypeId::of::<T>().into();
231        self
232    }
233
234    /// Set the type and id of the notification, used to uniquely identify the notification.
235    pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
236        self.id = (TypeId::of::<T>(), key.into()).into();
237        self
238    }
239
240    /// Set the title of the notification, default is None.
241    ///
242    /// If title is None, the notification will not have a title.
243    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
244        self.title = Some(title.into());
245        self
246    }
247
248    /// Set the icon of the notification.
249    ///
250    /// If icon is None, the notification will use the default icon of the type.
251    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
252        self.icon = Some(icon.into());
253        self
254    }
255
256    /// Set the type of the notification, default is NotificationType::Info.
257    pub fn with_type(mut self, type_: NotificationType) -> Self {
258        self.type_ = Some(type_);
259        self
260    }
261
262    /// Set the placement of the notification, overriding the global
263    /// [`NotificationSettings::placement`].
264    ///
265    /// Notifications are stacked separately for each placement.
266    pub fn placement(mut self, placement: Anchor) -> Self {
267        self.placement = Some(placement);
268        self
269    }
270
271    /// Set where this notification is delivered, overriding the global
272    /// [`NotificationSettings::delivery`].
273    ///
274    /// System delivery uses the OS notification center: the title and message
275    /// become the system notification's title and body (a notification with
276    /// neither is not posted). Pushing again with the same [`Notification::id`]
277    /// replaces the previous system notification. Clicking the system
278    /// notification activates the window, closes the in-app toast (if any),
279    /// and fires [`Notification::on_click`]; with
280    /// [`NotificationDelivery::System`] the toast never exists, so
281    /// [`Notification::on_close`] is never called.
282    ///
283    /// Platform requirements: on macOS the application must run from a bundled
284    /// `.app` in a location the system trusts, such as `/Applications` (posts
285    /// are silently dropped under plain `cargo run`, and the first post
286    /// triggers the authorization prompt); on Windows the application must
287    /// call [`gpui::App::set_app_identity`] early in startup; on Linux a
288    /// notification daemon must be present and retraction is unsupported
289    /// (dismissed notifications age out).
290    pub fn delivery(mut self, delivery: NotificationDelivery) -> Self {
291        self.delivery = Some(delivery);
292        self
293    }
294
295    /// Deliver this notification only to the OS notification center.
296    ///
297    /// Shorthand for [`Notification::delivery`] with
298    /// [`NotificationDelivery::System`]; see there for platform requirements.
299    pub fn system(self) -> Self {
300        self.delivery(NotificationDelivery::System)
301    }
302
303    /// Deliver this notification as an in-app toast and to the OS
304    /// notification center.
305    ///
306    /// Shorthand for [`Notification::delivery`] with
307    /// [`NotificationDelivery::InAppAndSystem`]; see there for platform
308    /// requirements.
309    pub fn in_app_and_system(self) -> Self {
310        self.delivery(NotificationDelivery::InAppAndSystem)
311    }
312
313    /// Set the auto hide of the notification, default is true.
314    pub fn autohide(mut self, autohide: bool) -> Self {
315        self.autohide = autohide;
316        self
317    }
318
319    /// Set the click callback of the notification.
320    pub fn on_click(
321        mut self,
322        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
323    ) -> Self {
324        self.on_click = Some(Rc::new(on_click));
325        self
326    }
327
328    /// Set the close callback of the notification.
329    ///
330    /// Triggered when the notification is closed by any means
331    /// (close button, middle-click, autohide, click handler, or programmatic close).
332    pub fn on_close(mut self, on_close: impl Fn(&mut Window, &mut App) + 'static) -> Self {
333        self.on_close = Some(Rc::new(on_close));
334        self
335    }
336
337    /// Set the action button of the notification.
338    ///
339    /// When an action is set, the notification will not autohide.
340    pub fn action<F>(mut self, action: F) -> Self
341    where
342        F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
343    {
344        self.action_builder = Some(Rc::new(action));
345        self.autohide = false;
346        self
347    }
348
349    /// Dismiss the notification.
350    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
351        let _ = window;
352        cx.emit(DismissRequest);
353    }
354
355    fn begin_close(&mut self, cx: &mut Context<Self>) {
356        if self.transition_status != ToastTransitionStatus::Ending {
357            self.transition_status = ToastTransitionStatus::Ending;
358            cx.notify();
359        }
360    }
361
362    fn complete_enter(&mut self, cx: &mut Context<Self>) {
363        if self.transition_status == ToastTransitionStatus::Starting {
364            self.transition_status = ToastTransitionStatus::Present;
365            cx.notify();
366        }
367    }
368
369    fn complete_close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
370        cx.emit(DismissEvent);
371        if let Some(on_close) = self.on_close.clone() {
372            on_close(window, cx);
373        }
374    }
375
376    /// Set the content of the notification.
377    pub fn content(
378        mut self,
379        content: impl Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement + 'static,
380    ) -> Self {
381        self.content_builder = Some(Rc::new(content));
382        self
383    }
384}
385
386impl EventEmitter<DismissEvent> for Notification {}
387impl EventEmitter<DismissRequest> for Notification {}
388impl FluentBuilder for Notification {}
389impl Styled for Notification {
390    fn style(&mut self) -> &mut StyleRefinement {
391        &mut self.style
392    }
393}
394
395impl Render for Notification {
396    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
397        let content = self
398            .content_builder
399            .clone()
400            .map(|builder| builder(self, window, cx));
401        let action = self
402            .action_builder
403            .clone()
404            .map(|builder| builder(self, window, cx).small().mr_3p5());
405
406        let transition_status = self.transition_status;
407        let closing = transition_status == ToastTransitionStatus::Ending;
408        let icon = match self.type_ {
409            None => self.icon.clone(),
410            Some(type_) => Some(type_.icon(cx)),
411        };
412        let has_icon = icon.is_some();
413        let placement = self.placement.unwrap_or(cx.theme().notification.placement);
414
415        BaseToast::new("notification")
416            .transition_status(transition_status)
417            .h_flex()
418            .group("")
419            .occlude()
420            .relative()
421            .w_full()
422            .border_1()
423            .border_color(cx.theme().border)
424            .bg(cx.theme().tokens.popover)
425            .rounded(cx.theme().radius_lg)
426            .shadow(toast_shadow(1.))
427            .py_3p5()
428            .px_4()
429            .gap_3()
430            .refine_style(&self.style)
431            .when_some(icon, |this, icon| {
432                this.child(div().absolute().top(px(18.)).left_4().child(icon))
433            })
434            .child(
435                v_flex()
436                    .flex_1()
437                    .overflow_hidden()
438                    .when(has_icon, |this| this.pl_6())
439                    .when_some(self.title.clone(), |this, title| {
440                        this.child(div().text_sm().font_semibold().child(title))
441                    })
442                    .when_some(self.message.clone(), |this, message| {
443                        this.child(div().text_sm().child(message))
444                    })
445                    .when_some(content, |this, content| this.child(content)),
446            )
447            .when_some(action, |this, action| this.child(action))
448            .child(
449                div()
450                    .absolute()
451                    .top_1()
452                    .right_1()
453                    .invisible()
454                    .group_hover("", |this| this.visible())
455                    .child(
456                        Button::new("close")
457                            .icon(IconName::Close)
458                            .ghost()
459                            .xsmall()
460                            .on_click(cx.listener(|this, _, window, cx| {
461                                cx.stop_propagation();
462                                this.dismiss(window, cx);
463                            })),
464                    ),
465            )
466            .when_some(self.on_click.clone(), |this, on_click| {
467                this.on_click(cx.listener(move |view, event, window, cx| {
468                    view.dismiss(window, cx);
469                    on_click(event, window, cx);
470                }))
471            })
472            .on_aux_click(cx.listener(move |view, event: &ClickEvent, window, cx| {
473                if event.is_middle_click() {
474                    view.dismiss(window, cx);
475                }
476            }))
477            .with_animation(
478                ElementId::NamedInteger("slide-down".into(), closing as u64),
479                Animation::new(if closing {
480                    NOTIFICATION_EXIT_DURATION
481                } else {
482                    NOTIFICATION_TRANSITION_DURATION
483                })
484                .with_easing(cubic_bezier(0.25, 0.1, 0.25, 1.)),
485                move |this, delta| {
486                    if closing {
487                        let opacity = 1. - delta;
488                        let that = this.opacity(opacity).shadow(toast_shadow(opacity.powi(3)));
489                        let y_offset = match placement {
490                            Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
491                                -delta * NOTIFICATION_TRANSITION_OFFSET
492                            }
493                            Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
494                                delta * NOTIFICATION_TRANSITION_OFFSET
495                            }
496                            _ => px(0.),
497                        };
498                        that.top(y_offset)
499                    } else {
500                        let y_offset = match placement {
501                            Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
502                                -NOTIFICATION_TRANSITION_OFFSET
503                                    + delta * NOTIFICATION_TRANSITION_OFFSET
504                            }
505                            Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
506                                NOTIFICATION_TRANSITION_OFFSET
507                                    - delta * NOTIFICATION_TRANSITION_OFFSET
508                            }
509                            _ => px(0.),
510                        };
511                        let opacity = delta;
512                        // GPUI paints a drop shadow *under* the card and has no
513                        // group compositing, so a card that is still translucent
514                        // does not hide its own shadow and it shows through as a
515                        // dark slab. Ramping the ink by the cube of the fade
516                        // keeps it out of sight until the card can cover it —
517                        // where this used to drop the shadow outright past a
518                        // threshold, which popped.
519                        this.top(px(0.) + y_offset)
520                            .opacity(opacity)
521                            .shadow(toast_shadow(opacity.powi(3)))
522                    }
523                },
524            )
525    }
526}
527
528/// The settings for notifications.
529#[derive(Debug, Clone)]
530pub struct NotificationSettings {
531    /// The placement of the notifications, default: [`Anchor::TopRight`]
532    ///
533    /// A single notification can override this with [`Notification::placement`].
534    pub placement: Anchor,
535    /// The margins of the notification with respect to the window edges.
536    pub margins: Edges<Pixels>,
537    /// The maximum number of notifications to show at once, default: 10
538    pub max_items: usize,
539    /// The width of the notifications, default: 382px
540    pub width: Pixels,
541    /// Where notifications are delivered, default: [`NotificationDelivery::InApp`]
542    ///
543    /// A single notification can override this with [`Notification::delivery`].
544    pub delivery: NotificationDelivery,
545}
546
547impl Default for NotificationSettings {
548    fn default() -> Self {
549        let offset = px(16.);
550        Self {
551            placement: Anchor::TopRight,
552            margins: Edges {
553                top: TITLE_BAR_HEIGHT + offset, // avoid overlap with title bar
554                right: offset,
555                bottom: offset,
556                left: offset,
557            },
558            max_items: 10,
559            width: DEFAULT_NOTIFICATION_WIDTH,
560            delivery: NotificationDelivery::default(),
561        }
562    }
563}
564
565/// Registers the app-global system-notification response handler.
566///
567/// gpui keeps a single such handler (later registrations replace earlier
568/// ones), so gpui-component owns it: applications must not call
569/// [`gpui::App::on_system_notification_response`] after `gpui_component::init`.
570/// Responses to notifications the application posts itself are ignored here.
571pub(crate) fn init(cx: &mut App) {
572    cx.on_system_notification_response(SystemNotificationRegistry::handle_response);
573}
574
575struct SystemNotificationEntry {
576    window: AnyWindowHandle,
577    list: WeakEntity<NotificationList>,
578    id: NotificationId,
579    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
580}
581
582const MAX_SYSTEM_NOTIFICATION_ENTRIES: usize = 100;
583
584/// App-global map from system-notification tag to the state needed to
585/// dispatch the user's response, ordered oldest-first so overflow prunes the
586/// stalest entries.
587#[derive(Default)]
588struct SystemNotificationRegistry {
589    entries: Vec<(SharedString, SystemNotificationEntry)>,
590}
591
592impl Global for SystemNotificationRegistry {}
593
594impl SystemNotificationRegistry {
595    fn insert(cx: &mut App, tag: SharedString, entry: SystemNotificationEntry) {
596        let entries = &mut cx.default_global::<Self>().entries;
597        entries.retain(|(existing, _)| *existing != tag);
598        entries.push((tag, entry));
599        if entries.len() > MAX_SYSTEM_NOTIFICATION_ENTRIES {
600            let overflow = entries.len() - MAX_SYSTEM_NOTIFICATION_ENTRIES;
601            entries.drain(..overflow);
602        }
603    }
604
605    /// Retract the system notification for `id`, but only if it was posted
606    /// from the given window: another window pushing the same id owns the tag
607    /// now, and this window's local dismiss must not clobber it.
608    fn dismiss(cx: &mut App, id: &NotificationId, window_id: WindowId) {
609        let tag = id.system_tag();
610        let entries = &mut cx.default_global::<Self>().entries;
611        let Some(ix) = entries.iter().position(|(existing, entry)| {
612            *existing == tag && entry.window.window_id() == window_id
613        }) else {
614            return;
615        };
616        entries.remove(ix);
617        cx.dismiss_system_notification(&tag);
618    }
619
620    /// Retract all of the window's system notifications whose id matches the
621    /// given [`TypeId`], regardless of the [`NotificationId`] variant.
622    fn dismiss_by_type(cx: &mut App, type_id: TypeId, window_id: WindowId) {
623        Self::dismiss_matching(cx, window_id, |id| match id {
624            NotificationId::Id(t) | NotificationId::IdAndElementId(t, _) => *t == type_id,
625        });
626    }
627
628    /// Retract all system notifications posted from the given window.
629    fn dismiss_all(cx: &mut App, window_id: WindowId) {
630        Self::dismiss_matching(cx, window_id, |_| true);
631    }
632
633    fn dismiss_matching(
634        cx: &mut App,
635        window_id: WindowId,
636        matches: impl Fn(&NotificationId) -> bool,
637    ) {
638        let entries = &mut cx.default_global::<Self>().entries;
639        let mut tags = Vec::new();
640        entries.retain(|(tag, entry)| {
641            if entry.window.window_id() == window_id && matches(&entry.id) {
642                tags.push(tag.clone());
643                false
644            } else {
645                true
646            }
647        });
648        for tag in tags {
649            cx.dismiss_system_notification(&tag);
650        }
651    }
652
653    fn handle_response(response: SystemNotificationResponse, cx: &mut App) {
654        if !response.tag.starts_with(SYSTEM_TAG_PREFIX) {
655            return;
656        }
657        // Platforms usually remove a clicked notification themselves; retract
658        // explicitly for the ones that keep it in the notification center.
659        cx.dismiss_system_notification(&response.tag);
660        // The prefix already proves the notification is ours, so activate even
661        // when nothing is left to dispatch to: the platform can deliver a
662        // response for a notification posted before the process restarted, and
663        // entries are pruned past `MAX_SYSTEM_NOTIFICATION_ENTRIES`.
664        cx.activate(true);
665        let entries = &mut cx.default_global::<Self>().entries;
666        let Some(ix) = entries.iter().position(|(tag, _)| *tag == response.tag) else {
667            return;
668        };
669        let (_, entry) = entries.remove(ix);
670        // Errs when the window is already closed: the entry is removed and the
671        // application is still brought to the foreground.
672        let _ = entry.window.update(cx, |_, window, cx| {
673            window.activate_window();
674            if let Some(list) = entry.list.upgrade() {
675                // No-op when the toast already closed or was never created
676                // (system-only delivery).
677                list.update(cx, |list, cx| list.close(entry.id.clone(), window, cx));
678            }
679            if let Some(on_click) = entry.on_click {
680                on_click(&ClickEvent::default(), window, cx);
681            }
682        });
683    }
684}
685
686/// Per-placement stack state, created lazily for placements in use.
687struct AnchorStack {
688    state: ToastStackState,
689    focus_handle: FocusHandle,
690}
691
692/// A list of notifications.
693pub struct NotificationList {
694    /// Notifications that will be auto hidden.
695    pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
696    stacks: Vec<(Anchor, AnchorStack)>,
697    focus_handle: FocusHandle,
698    /// Whether the lifecycle clock is running. The loop clears it as it exits.
699    is_advancing: bool,
700    _subscriptions: HashMap<NotificationId, Subscription>,
701}
702
703impl NotificationList {
704    pub fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
705        Self {
706            notifications: ToastManager::new(ToastMotion::sonner()),
707            stacks: Vec::new(),
708            focus_handle: cx.focus_handle().tab_stop(true),
709            is_advancing: false,
710            _subscriptions: HashMap::new(),
711        }
712    }
713
714    /// Tick the toast lifecycle until the last notification is unmounted.
715    ///
716    /// It advances the transition phases and samples the pause input (stack
717    /// expansion) that reaches the list through no event.
718    /// With nothing mounted there is nothing to do, so an idle window arms no
719    /// timer.
720    fn start_advancing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
721        if self.is_advancing {
722            return;
723        }
724        self.is_advancing = true;
725        // Detached rather than kept as a `Task`: the loop ends itself, and a
726        // handle on the list would have to be dropped from inside its own future.
727        cx.spawn_in(window, async move |view, cx| {
728            loop {
729                cx.background_executor()
730                    .timer(NOTIFICATION_ADVANCE_INTERVAL)
731                    .await;
732                let running = view.update_in(cx, |view, window, cx| {
733                    view.advance(window, cx);
734                    view.is_advancing = !view.notifications.is_empty();
735                    view.is_advancing
736                });
737                if !matches!(running, Ok(true)) {
738                    break;
739                }
740            }
741        })
742        .detach();
743    }
744
745    fn is_expanded(&self) -> bool {
746        self.stacks
747            .iter()
748            .any(|(_, stack)| stack.state.is_expanded())
749    }
750
751    /// Group the visible notifications by their effective placement, pairing
752    /// each group with the element id its stack renders under and preserving
753    /// the display order within each group.
754    fn grouped(
755        &self,
756        cx: &App,
757    ) -> Vec<(
758        Anchor,
759        ElementId,
760        Vec<(NotificationId, Entity<Notification>)>,
761    )> {
762        let settings = &cx.theme().notification;
763        let (max_items, placement) = (settings.max_items, settings.placement);
764
765        let mut groups: Vec<(Anchor, Vec<(NotificationId, Entity<Notification>)>)> = Vec::new();
766        for (id, item, _) in self.notifications.visible(max_items) {
767            let anchor = item.read(cx).placement.unwrap_or(placement);
768            match groups.iter_mut().find(|(a, _)| *a == anchor) {
769                Some((_, items)) => items.push((id.clone(), item.clone())),
770                None => groups.push((anchor, vec![(id.clone(), item.clone())])),
771            }
772        }
773        groups
774            .into_iter()
775            .map(|(anchor, items)| (anchor, Self::stack_id(anchor), items))
776            .collect()
777    }
778
779    /// The element id a placement's stack renders under.
780    ///
781    /// Keyed by the placement itself rather than by position among the mounted
782    /// stacks: a stack whose id changes loses the element state of its whole
783    /// subtree, which replays the enter animation of every notification in it.
784    fn stack_id(anchor: Anchor) -> ElementId {
785        let ix = match anchor {
786            Anchor::TopLeft => 0,
787            Anchor::TopCenter => 1,
788            Anchor::TopRight => 2,
789            Anchor::BottomLeft => 3,
790            Anchor::BottomCenter => 4,
791            Anchor::BottomRight => 5,
792            Anchor::LeftCenter => 6,
793            Anchor::RightCenter => 7,
794        };
795        ("notification-list", ix as usize).into()
796    }
797
798    pub fn push(
799        &mut self,
800        notification: impl Into<Notification>,
801        window: &mut Window,
802        cx: &mut Context<Self>,
803    ) {
804        let notification = notification.into();
805        let delivery = notification
806            .delivery
807            .unwrap_or(cx.theme().notification.delivery);
808        if delivery.includes_system() {
809            Self::push_system(&notification, window, cx);
810        }
811        if !delivery.includes_in_app() {
812            return;
813        }
814
815        let id = notification.id.clone();
816        let autohide = notification.autohide;
817        let window_id = window.window_handle().window_id();
818
819        let notification = cx.new(|_| notification);
820
821        let dismiss_id = id.clone();
822        self._subscriptions.insert(
823            id.clone(),
824            cx.subscribe(&notification, move |view, _, _: &DismissRequest, cx| {
825                if view
826                    .notifications
827                    .dismiss(&dismiss_id, cx.background_executor().now())
828                {
829                    if let Some(note) = view.notifications.get(&dismiss_id) {
830                        note.update(cx, |note, cx| note.begin_close(cx));
831                    }
832                    SystemNotificationRegistry::dismiss(cx, &dismiss_id, window_id);
833                }
834            }),
835        );
836
837        self.notifications.push(
838            id,
839            notification,
840            ToastOptions {
841                timeout: autohide.then_some(Duration::from_secs(5)),
842            },
843            cx.background_executor().now(),
844        );
845        self.start_advancing(window, cx);
846        cx.notify();
847    }
848
849    /// Post the notification to the OS notification center and register the
850    /// state needed to dispatch the user's response back to this window.
851    fn push_system(notification: &Notification, window: &Window, cx: &mut Context<Self>) {
852        let (title, body) = match (&notification.title, &notification.message) {
853            (Some(title), message) => (title.clone(), message.clone().unwrap_or_default()),
854            (None, Some(message)) => (message.clone(), SharedString::default()),
855            // A content-only notification has nothing textual to show.
856            (None, None) => return,
857        };
858        let tag = notification.id.system_tag();
859        let entry = SystemNotificationEntry {
860            window: window.window_handle(),
861            list: cx.entity().downgrade(),
862            id: notification.id.clone(),
863            on_click: notification.on_click.clone(),
864        };
865        SystemNotificationRegistry::insert(cx, tag.clone(), entry);
866        cx.show_system_notification(SystemNotification {
867            tag,
868            title,
869            body,
870            actions: Vec::new(),
871        });
872    }
873
874    fn advance(&mut self, window: &mut Window, cx: &mut Context<Self>) {
875        let changes = self
876            .notifications
877            .advance(cx.background_executor().now(), self.is_expanded());
878        for id in changes.presented {
879            if let Some(note) = self.notifications.get(&id) {
880                note.update(cx, |note, cx| note.complete_enter(cx));
881            }
882        }
883        for id in changes.ending {
884            if let Some(note) = self.notifications.get(&id) {
885                note.update(cx, |note, cx| note.begin_close(cx));
886            }
887        }
888        for (id, note) in changes.removed {
889            self._subscriptions.remove(&id);
890            note.update(cx, |note, cx| note.complete_close(window, cx));
891        }
892        if changes.changed {
893            cx.notify();
894        }
895    }
896
897    pub(crate) fn close(
898        &mut self,
899        id: impl Into<NotificationId>,
900        window: &mut Window,
901        cx: &mut Context<Self>,
902    ) {
903        let id: NotificationId = id.into();
904        // Unconditional: a system-only notification has no toast to dismiss,
905        // but its system counterpart must still be retracted.
906        SystemNotificationRegistry::dismiss(cx, &id, window.window_handle().window_id());
907        if self
908            .notifications
909            .dismiss(&id, cx.background_executor().now())
910        {
911            if let Some(n) = self.notifications.get(&id) {
912                n.update(cx, |note, cx| note.begin_close(cx))
913            }
914        }
915        cx.notify();
916    }
917
918    /// Close all notifications whose id matches the given [`TypeId`], regardless of
919    /// whether they were registered via [`Notification::id`] or [`Notification::id1`].
920    pub(crate) fn close_by_type(
921        &mut self,
922        type_id: TypeId,
923        window: &mut Window,
924        cx: &mut Context<Self>,
925    ) {
926        SystemNotificationRegistry::dismiss_by_type(
927            cx,
928            type_id,
929            window.window_handle().window_id(),
930        );
931        let matched: Vec<_> = self
932            .notifications
933            .iter()
934            .filter(|(id, _, _)| match id {
935                NotificationId::Id(t) | NotificationId::IdAndElementId(t, _) => *t == type_id,
936            })
937            .map(|(_, notification, _)| notification)
938            .cloned()
939            .collect();
940        for n in matched {
941            let id = n.read(cx).id.clone();
942            if self
943                .notifications
944                .dismiss(&id, cx.background_executor().now())
945            {
946                n.update(cx, |note, cx| note.begin_close(cx));
947            }
948        }
949        cx.notify();
950    }
951
952    pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
953        SystemNotificationRegistry::dismiss_all(cx, window.window_handle().window_id());
954        for id in self
955            .notifications
956            .dismiss_all(cx.background_executor().now())
957        {
958            if let Some(note) = self.notifications.get(&id) {
959                note.update(cx, |note, cx| note.begin_close(cx));
960            }
961        }
962        cx.notify();
963    }
964
965    pub fn notifications(&self) -> Vec<Entity<Notification>> {
966        self.notifications
967            .iter()
968            .map(|(_, value, _)| value.clone())
969            .collect()
970    }
971}
972
973impl Render for NotificationList {
974    fn render(
975        &mut self,
976        window: &mut gpui::Window,
977        cx: &mut gpui::Context<Self>,
978    ) -> impl IntoElement {
979        let size = window.viewport_size();
980        let settings = &cx.theme().notification;
981        let (placement, margins, width) =
982            (settings.placement, settings.margins.clone(), settings.width);
983
984        let groups = self.grouped(cx);
985        self.stacks
986            .retain(|(anchor, _)| groups.iter().any(|(a, _, _)| a == anchor));
987
988        let default_focus_handle = self.focus_handle.clone();
989        let stacks = groups.into_iter().map(|(anchor, stack_id, items)| {
990            let stack_ix = self
991                .stacks
992                .iter()
993                .position(|(a, _)| *a == anchor)
994                .unwrap_or_else(|| {
995                    self.stacks.push((
996                        anchor,
997                        AnchorStack {
998                            state: ToastStackState::default(),
999                            focus_handle: if anchor == placement {
1000                                default_focus_handle.clone()
1001                            } else {
1002                                cx.focus_handle().tab_stop(true)
1003                            },
1004                        },
1005                    ));
1006                    self.stacks.len() - 1
1007                });
1008            let stack = &self.stacks[stack_ix].1;
1009
1010            items
1011                .into_iter()
1012                .fold(
1013                    ToastStack::new(stack_id, stack.state.clone()),
1014                    |stack, (id, item)| stack.item(format!("{id:?}"), item),
1015                )
1016                .placement(anchor)
1017                .focus_handle(stack.focus_handle.clone())
1018                .v_flex()
1019                .w(width)
1020                .max_h(size.height)
1021                .absolute()
1022                .map(|this| match anchor {
1023                    Anchor::TopLeft => this.top(margins.top).left(margins.left),
1024                    Anchor::TopRight => this.top(margins.top).right(margins.right),
1025                    Anchor::TopCenter => this.top(margins.top).left_0().right_0().mx_auto(),
1026                    Anchor::BottomLeft => this.bottom(margins.bottom).left(margins.left),
1027                    Anchor::BottomRight => this.bottom(margins.bottom).right(margins.right),
1028                    Anchor::BottomCenter => {
1029                        this.bottom(margins.bottom).left_0().right_0().mx_auto()
1030                    }
1031                    Anchor::LeftCenter => this.left(margins.left).top_0().bottom_0().my_auto(),
1032                    Anchor::RightCenter => this.right(margins.right).top_0().bottom_0().my_auto(),
1033                })
1034        });
1035
1036        div().size_full().children(stacks)
1037    }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043    use crate::theme::Theme;
1044    use gpui::{TestAppContext, VisualTestContext};
1045
1046    struct FooKind;
1047    struct BarKind;
1048
1049    struct TestRoot {
1050        list: Entity<NotificationList>,
1051        other_focus: FocusHandle,
1052    }
1053
1054    impl Render for TestRoot {
1055        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1056            div()
1057                .child(self.list.clone())
1058                .child(div().track_focus(&self.other_focus))
1059        }
1060    }
1061
1062    fn ids(list: &Entity<NotificationList>, cx: &mut VisualTestContext) -> Vec<NotificationId> {
1063        list.read_with(cx, |l, cx| {
1064            l.notifications
1065                .iter()
1066                .map(|(_, n, _)| n.read(cx).id.clone())
1067                .collect()
1068        })
1069    }
1070
1071    /// Drive the dismiss animation timer + propagate the resulting `DismissEvent`
1072    /// so that closed notifications are removed from the list.
1073    fn flush_dismiss(cx: &mut VisualTestContext) {
1074        cx.background_executor
1075            .advance_clock(NOTIFICATION_EXIT_DURATION + Duration::from_millis(50));
1076        cx.run_until_parked();
1077    }
1078
1079    #[test]
1080    fn test_notification_builder() {
1081        let note = Notification::new()
1082            .title("title")
1083            .message("message")
1084            .with_type(NotificationType::Success)
1085            .placement(Anchor::BottomLeft)
1086            .delivery(NotificationDelivery::System)
1087            .autohide(false);
1088        assert_eq!(note.title, Some("title".into()));
1089        assert_eq!(note.message, Some("message".into()));
1090        assert!(matches!(note.type_, Some(NotificationType::Success)));
1091        assert_eq!(note.placement, Some(Anchor::BottomLeft));
1092        assert_eq!(note.delivery, Some(NotificationDelivery::System));
1093        assert!(!note.autohide);
1094
1095        assert_eq!(Notification::new().delivery, None);
1096        assert_eq!(
1097            Notification::new().system().delivery,
1098            Some(NotificationDelivery::System)
1099        );
1100        assert_eq!(
1101            Notification::new().in_app_and_system().delivery,
1102            Some(NotificationDelivery::InAppAndSystem)
1103        );
1104    }
1105
1106    #[test]
1107    fn notification_delivery_includes() {
1108        assert!(NotificationDelivery::InApp.includes_in_app());
1109        assert!(!NotificationDelivery::InApp.includes_system());
1110        assert!(!NotificationDelivery::System.includes_in_app());
1111        assert!(NotificationDelivery::System.includes_system());
1112        assert!(NotificationDelivery::InAppAndSystem.includes_in_app());
1113        assert!(NotificationDelivery::InAppAndSystem.includes_system());
1114    }
1115
1116    #[test]
1117    fn system_tag_is_stable_and_namespaced() {
1118        let by_type = NotificationId::from(TypeId::of::<FooKind>());
1119        assert_eq!(by_type.system_tag(), by_type.clone().system_tag());
1120        assert!(by_type.system_tag().starts_with(SYSTEM_TAG_PREFIX));
1121
1122        let key1 = NotificationId::from((TypeId::of::<FooKind>(), ElementId::from(1usize)));
1123        let key2 = NotificationId::from((TypeId::of::<FooKind>(), ElementId::from(2usize)));
1124        assert_ne!(key1.system_tag(), key2.system_tag());
1125        assert_ne!(by_type.system_tag(), key1.system_tag());
1126
1127        // Default (uuid) ids never collide, so they never replace each other.
1128        assert_ne!(
1129            Notification::new().id.system_tag(),
1130            Notification::new().id.system_tag()
1131        );
1132    }
1133
1134    /// A stack's element id must not depend on how many other placements are
1135    /// mounted: a changing id drops the element state of the whole subtree,
1136    /// which replays each notification's enter animation.
1137    #[gpui::test]
1138    fn stack_element_id_survives_other_placements_disappearing(cx: &mut TestAppContext) {
1139        cx.update(|cx| cx.set_global(Theme::default()));
1140        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1141            list: cx.new(|cx| NotificationList::new(window, cx)),
1142            other_focus: cx.focus_handle(),
1143        });
1144        cx.update(|window, _| window.activate_window());
1145        let list = root.read_with(cx, |root, _| root.list.clone());
1146
1147        // The left stack is pushed first, so it owns the lower group index.
1148        list.update_in(cx, |list, window, cx| {
1149            list.push(
1150                Notification::info("left")
1151                    .id::<FooKind>()
1152                    .placement(Anchor::BottomLeft)
1153                    .autohide(false),
1154                window,
1155                cx,
1156            );
1157            list.push(
1158                Notification::info("right").id::<BarKind>().autohide(false),
1159                window,
1160                cx,
1161            );
1162        });
1163
1164        let right_id = |cx: &mut VisualTestContext| {
1165            list.read_with(cx, |list, cx| {
1166                list.grouped(cx)
1167                    .into_iter()
1168                    .find(|(anchor, _, _)| *anchor == Anchor::TopRight)
1169                    .map(|(_, id, _)| id)
1170                    .expect("the right stack is mounted")
1171            })
1172        };
1173        let before = right_id(cx);
1174
1175        list.update_in(cx, |list, window, cx| {
1176            list.close(TypeId::of::<FooKind>(), window, cx);
1177        });
1178        flush_dismiss(cx);
1179
1180        assert_eq!(right_id(cx), before);
1181    }
1182
1183    #[gpui::test]
1184    fn per_notification_placement_stacks_by_anchor(cx: &mut TestAppContext) {
1185        cx.update(|cx| cx.set_global(Theme::default()));
1186        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1187            list: cx.new(|cx| NotificationList::new(window, cx)),
1188            other_focus: cx.focus_handle(),
1189        });
1190        cx.update(|window, _| window.activate_window());
1191        let list = root.read_with(cx, |root, _| root.list.clone());
1192
1193        list.update_in(cx, |list, window, cx| {
1194            list.push(
1195                Notification::info("default")
1196                    .id::<FooKind>()
1197                    .autohide(false),
1198                window,
1199                cx,
1200            );
1201            list.push(
1202                Notification::info("bottom")
1203                    .id::<BarKind>()
1204                    .placement(Anchor::BottomLeft)
1205                    .autohide(false),
1206                window,
1207                cx,
1208            );
1209        });
1210        cx.update(|window, cx| window.draw(cx).clear(cx));
1211
1212        let anchors =
1213            |list: &NotificationList| list.stacks.iter().map(|(a, _)| *a).collect::<Vec<_>>();
1214        assert_eq!(
1215            list.read_with(cx, |list, _| anchors(list)),
1216            [Anchor::TopRight, Anchor::BottomLeft]
1217        );
1218
1219        // Dismissing the overridden notification prunes its stack.
1220        list.update_in(cx, |list, window, cx| {
1221            list.close(TypeId::of::<BarKind>(), window, cx);
1222        });
1223        flush_dismiss(cx);
1224        cx.update(|window, cx| window.draw(cx).clear(cx));
1225        assert_eq!(
1226            list.read_with(cx, |list, _| anchors(list)),
1227            [Anchor::TopRight]
1228        );
1229    }
1230
1231    #[gpui::test]
1232    fn closing_toast_stays_mounted_until_its_transition_finishes(cx: &mut TestAppContext) {
1233        cx.update(|cx| cx.set_global(Theme::default()));
1234        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1235            list: cx.new(|cx| NotificationList::new(window, cx)),
1236            other_focus: cx.focus_handle(),
1237        });
1238        cx.update(|window, _| window.activate_window());
1239        let list = root.read_with(cx, |root, _| root.list.clone());
1240
1241        list.update_in(cx, |list, window, cx| {
1242            list.push(
1243                Notification::info("closing")
1244                    .id::<FooKind>()
1245                    .autohide(false),
1246                window,
1247                cx,
1248            );
1249            list.close(TypeId::of::<FooKind>(), window, cx);
1250        });
1251
1252        cx.background_executor
1253            .advance_clock(NOTIFICATION_EXIT_DURATION - Duration::from_millis(1));
1254        cx.run_until_parked();
1255        assert_eq!(ids(&list, cx).len(), 1);
1256
1257        cx.background_executor
1258            .advance_clock(Duration::from_millis(1));
1259        cx.run_until_parked();
1260        assert!(ids(&list, cx).is_empty());
1261    }
1262
1263    #[gpui::test]
1264    fn lifecycle_clock_runs_only_while_a_notification_is_mounted(cx: &mut TestAppContext) {
1265        cx.update(|cx| cx.set_global(Theme::default()));
1266        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1267            list: cx.new(|cx| NotificationList::new(window, cx)),
1268            other_focus: cx.focus_handle(),
1269        });
1270        cx.update(|window, _| window.activate_window());
1271        let list = root.read_with(cx, |root, _| root.list.clone());
1272
1273        // A list that has never shown anything arms no timer.
1274        cx.background_executor.advance_clock(Duration::from_secs(1));
1275        cx.run_until_parked();
1276        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1277
1278        list.update_in(cx, |list, window, cx| {
1279            list.push(Notification::info("tick").id::<FooKind>(), window, cx);
1280        });
1281        assert!(list.read_with(cx, |list, _| list.is_advancing));
1282
1283        // Autohide expiry plus the exit transition stops the clock with it.
1284        cx.background_executor
1285            .advance_clock(NOTIFICATION_TRANSITION_DURATION + Duration::from_secs(5));
1286        cx.run_until_parked();
1287        flush_dismiss(cx);
1288        assert!(ids(&list, cx).is_empty());
1289        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1290
1291        // A later notification restarts it.
1292        list.update_in(cx, |list, window, cx| {
1293            list.push(Notification::info("again").id::<BarKind>(), window, cx);
1294        });
1295        assert!(list.read_with(cx, |list, _| list.is_advancing));
1296        cx.background_executor
1297            .advance_clock(NOTIFICATION_TRANSITION_DURATION + Duration::from_secs(5));
1298        cx.run_until_parked();
1299        flush_dismiss(cx);
1300        assert!(ids(&list, cx).is_empty());
1301        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1302    }
1303
1304    #[gpui::test]
1305    fn inactive_window_does_not_pause_autohide(cx: &mut TestAppContext) {
1306        cx.update(|cx| cx.set_global(Theme::default()));
1307        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1308            list: cx.new(|cx| NotificationList::new(window, cx)),
1309            other_focus: cx.focus_handle(),
1310        });
1311        let list = root.read_with(cx, |root, _| root.list.clone());
1312        list.update_in(cx, |list, window, cx| {
1313            assert!(!window.is_window_active());
1314            list.push(Notification::info("background").id::<FooKind>(), window, cx);
1315        });
1316
1317        cx.background_executor
1318            .advance_clock(NOTIFICATION_TRANSITION_DURATION);
1319        cx.run_until_parked();
1320        let note = list.read_with(cx, |list, _| {
1321            list.notifications
1322                .get(&NotificationId::from(TypeId::of::<FooKind>()))
1323                .unwrap()
1324                .clone()
1325        });
1326        assert_eq!(
1327            note.read_with(cx, |note, _| note.transition_status),
1328            ToastTransitionStatus::Present
1329        );
1330
1331        cx.background_executor
1332            .advance_clock(Duration::from_secs(5) + Duration::from_millis(50));
1333        cx.run_until_parked();
1334        assert_eq!(
1335            note.read_with(cx, |note, _| note.transition_status),
1336            ToastTransitionStatus::Ending
1337        );
1338    }
1339
1340    #[gpui::test]
1341    fn focus_pauses_autohide_and_present_phase_is_projected(cx: &mut TestAppContext) {
1342        cx.update(|cx| cx.set_global(Theme::default()));
1343        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1344            list: cx.new(|cx| NotificationList::new(window, cx)),
1345            other_focus: cx.focus_handle(),
1346        });
1347        let list = root.read_with(cx, |root, _| root.list.clone());
1348        list.update_in(cx, |list, window, cx| {
1349            list.push(Notification::info("paused").id::<FooKind>(), window, cx);
1350        });
1351
1352        cx.background_executor
1353            .advance_clock(NOTIFICATION_TRANSITION_DURATION);
1354        cx.run_until_parked();
1355        let note = list.read_with(cx, |list, _| {
1356            list.notifications
1357                .get(&NotificationId::from(TypeId::of::<FooKind>()))
1358                .unwrap()
1359                .clone()
1360        });
1361        assert_eq!(
1362            note.read_with(cx, |note, _| note.transition_status),
1363            ToastTransitionStatus::Present
1364        );
1365
1366        let list_focus = list.read_with(cx, |list, _| list.focus_handle.clone());
1367        cx.update(|window, cx| {
1368            list_focus.focus(window, cx);
1369            window.draw(cx).clear(cx);
1370        });
1371        cx.background_executor.advance_clock(Duration::from_secs(5));
1372        cx.run_until_parked();
1373        assert_eq!(
1374            note.read_with(cx, |note, _| note.transition_status),
1375            ToastTransitionStatus::Present
1376        );
1377
1378        let other_focus = root.read_with(cx, |root, _| root.other_focus.clone());
1379        cx.update(|window, cx| {
1380            other_focus.focus(window, cx);
1381            window.draw(cx).clear(cx);
1382        });
1383        assert!(!list.read_with(cx, |list, _| list.is_expanded()));
1384        cx.background_executor
1385            .advance_clock(Duration::from_secs(5) + Duration::from_millis(50));
1386        cx.run_until_parked();
1387        assert_eq!(
1388            note.read_with(cx, |note, _| note.transition_status),
1389            ToastTransitionStatus::Ending
1390        );
1391    }
1392
1393    #[gpui::test]
1394    fn close_by_type_removes_id_and_all_id1_of_same_type(cx: &mut TestAppContext) {
1395        cx.update(|cx| cx.set_global(Theme::default()));
1396        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1397            list: cx.new(|cx| NotificationList::new(window, cx)),
1398            other_focus: cx.focus_handle(),
1399        });
1400        let list = root.read_with(cx, |r, _| r.list.clone());
1401
1402        list.update_in(cx, |list, window, cx| {
1403            list.push(
1404                Notification::info("plain").id::<FooKind>().autohide(false),
1405                window,
1406                cx,
1407            );
1408            list.push(
1409                Notification::info("a").id1::<FooKind>(1).autohide(false),
1410                window,
1411                cx,
1412            );
1413            list.push(
1414                Notification::info("b").id1::<FooKind>(2).autohide(false),
1415                window,
1416                cx,
1417            );
1418            list.push(
1419                Notification::info("bar").id::<BarKind>().autohide(false),
1420                window,
1421                cx,
1422            );
1423        });
1424        cx.run_until_parked();
1425        assert_eq!(ids(&list, cx).len(), 4);
1426
1427        list.update_in(cx, |list, window, cx| {
1428            list.close_by_type(TypeId::of::<FooKind>(), window, cx);
1429        });
1430        flush_dismiss(cx);
1431
1432        let remaining = ids(&list, cx);
1433        assert_eq!(
1434            remaining,
1435            vec![NotificationId::Id(TypeId::of::<BarKind>())],
1436            "only the BarKind notification should survive"
1437        );
1438    }
1439
1440    #[gpui::test]
1441    fn close_with_id_and_element_id_removes_only_matching_key(cx: &mut TestAppContext) {
1442        cx.update(|cx| cx.set_global(Theme::default()));
1443        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1444            list: cx.new(|cx| NotificationList::new(window, cx)),
1445            other_focus: cx.focus_handle(),
1446        });
1447        let list = root.read_with(cx, |r, _| r.list.clone());
1448
1449        list.update_in(cx, |list, window, cx| {
1450            list.push(
1451                Notification::info("a").id1::<FooKind>(1).autohide(false),
1452                window,
1453                cx,
1454            );
1455            list.push(
1456                Notification::info("b").id1::<FooKind>(2).autohide(false),
1457                window,
1458                cx,
1459            );
1460            list.push(
1461                Notification::info("plain").id::<FooKind>().autohide(false),
1462                window,
1463                cx,
1464            );
1465        });
1466
1467        list.update_in(cx, |list, window, cx| {
1468            list.close(
1469                (TypeId::of::<FooKind>(), ElementId::from(1usize)),
1470                window,
1471                cx,
1472            );
1473        });
1474        flush_dismiss(cx);
1475
1476        let remaining = ids(&list, cx);
1477        assert_eq!(remaining.len(), 2);
1478        assert!(remaining.contains(&NotificationId::IdAndElementId(
1479            TypeId::of::<FooKind>(),
1480            ElementId::from(2usize),
1481        )));
1482        assert!(remaining.contains(&NotificationId::Id(TypeId::of::<FooKind>())));
1483    }
1484
1485    #[gpui::test]
1486    fn close_with_only_type_id_does_not_match_id1_entries(cx: &mut TestAppContext) {
1487        // The plain `close(TypeId)` form (used by the legacy code path) must keep
1488        // its narrow semantics: it only matches `NotificationId::Id`, not
1489        // `NotificationId::IdAndElementId`. The new `close_by_type` is the broad form.
1490        cx.update(|cx| cx.set_global(Theme::default()));
1491        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1492            list: cx.new(|cx| NotificationList::new(window, cx)),
1493            other_focus: cx.focus_handle(),
1494        });
1495        let list = root.read_with(cx, |r, _| r.list.clone());
1496
1497        list.update_in(cx, |list, window, cx| {
1498            list.push(
1499                Notification::info("a").id1::<FooKind>(1).autohide(false),
1500                window,
1501                cx,
1502            );
1503        });
1504
1505        list.update_in(cx, |list, window, cx| {
1506            list.close(TypeId::of::<FooKind>(), window, cx);
1507        });
1508        flush_dismiss(cx);
1509
1510        assert_eq!(ids(&list, cx).len(), 1, "id1 entry should remain untouched");
1511    }
1512
1513    #[gpui::test]
1514    fn close_by_type_with_no_match_is_noop(cx: &mut TestAppContext) {
1515        cx.update(|cx| cx.set_global(Theme::default()));
1516        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1517            list: cx.new(|cx| NotificationList::new(window, cx)),
1518            other_focus: cx.focus_handle(),
1519        });
1520        let list = root.read_with(cx, |r, _| r.list.clone());
1521
1522        list.update_in(cx, |list, window, cx| {
1523            list.push(
1524                Notification::info("bar").id::<BarKind>().autohide(false),
1525                window,
1526                cx,
1527            );
1528        });
1529
1530        list.update_in(cx, |list, window, cx| {
1531            list.close_by_type(TypeId::of::<FooKind>(), window, cx);
1532        });
1533        flush_dismiss(cx);
1534
1535        assert_eq!(ids(&list, cx).len(), 1);
1536    }
1537
1538    /// Applications may post their own system notifications; a response for
1539    /// one of those must not be retracted or dispatched by the library.
1540    #[gpui::test]
1541    fn response_for_a_foreign_tag_is_ignored(cx: &mut TestAppContext) {
1542        cx.update(|cx| {
1543            init(cx);
1544            cx.set_app_identity("com.example.test", "Test");
1545        });
1546        cx.simulate_system_notification_response(SystemNotificationResponse {
1547            tag: "com.example.app/own-tag".into(),
1548            action_id: None,
1549        });
1550        cx.run_until_parked();
1551        assert!(cx.dismissed_system_notifications().is_empty());
1552    }
1553
1554    #[gpui::test]
1555    fn system_delivery_posts_to_center_without_in_app_toast(cx: &mut TestAppContext) {
1556        cx.update(|cx| {
1557            cx.set_global(Theme::default());
1558            init(cx);
1559            // The test platform drops posts until an identity is set,
1560            // mirroring Windows.
1561            cx.set_app_identity("com.example.test", "Test");
1562        });
1563        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1564            list: cx.new(|cx| NotificationList::new(window, cx)),
1565            other_focus: cx.focus_handle(),
1566        });
1567        let list = root.read_with(cx, |r, _| r.list.clone());
1568
1569        list.update_in(cx, |list, window, cx| {
1570            list.push(
1571                Notification::info("body text")
1572                    .title("Title")
1573                    .id::<FooKind>()
1574                    .delivery(NotificationDelivery::System),
1575                window,
1576                cx,
1577            );
1578            // Message-only: the message becomes the system title.
1579            list.push(
1580                Notification::info("message only")
1581                    .id::<BarKind>()
1582                    .delivery(NotificationDelivery::System),
1583                window,
1584                cx,
1585            );
1586        });
1587        cx.run_until_parked();
1588
1589        let shown = cx.shown_system_notifications();
1590        assert_eq!(shown.len(), 2);
1591        assert_eq!(
1592            shown[0].tag,
1593            NotificationId::Id(TypeId::of::<FooKind>()).system_tag()
1594        );
1595        assert_eq!(shown[0].title, "Title");
1596        assert_eq!(shown[0].body, "body text");
1597        assert_eq!(shown[1].title, "message only");
1598        assert_eq!(shown[1].body, "");
1599        assert!(ids(&list, cx).is_empty(), "no in-app toast should exist");
1600
1601        // System-only notifications are still retractable through the
1602        // regular close path even though no toast entity exists.
1603        list.update_in(cx, |list, window, cx| {
1604            list.close(TypeId::of::<FooKind>(), window, cx);
1605        });
1606        assert_eq!(
1607            cx.dismissed_system_notifications(),
1608            vec![NotificationId::Id(TypeId::of::<FooKind>()).system_tag()]
1609        );
1610    }
1611
1612    #[gpui::test]
1613    fn system_click_activates_window_closes_toast_and_fires_on_click(cx: &mut TestAppContext) {
1614        cx.update(|cx| {
1615            cx.set_global(Theme::default());
1616            init(cx);
1617            cx.set_app_identity("com.example.test", "Test");
1618        });
1619        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1620            list: cx.new(|cx| NotificationList::new(window, cx)),
1621            other_focus: cx.focus_handle(),
1622        });
1623        let list = root.read_with(cx, |r, _| r.list.clone());
1624
1625        let clicked = Rc::new(std::cell::Cell::new(false));
1626        let on_click_flag = clicked.clone();
1627        list.update_in(cx, |list, window, cx| {
1628            list.push(
1629                Notification::info("message")
1630                    .title("Title")
1631                    .id::<FooKind>()
1632                    .delivery(NotificationDelivery::InAppAndSystem)
1633                    .autohide(false)
1634                    .on_click(move |_, _, _| on_click_flag.set(true)),
1635                window,
1636                cx,
1637            );
1638        });
1639        cx.run_until_parked();
1640        assert_eq!(ids(&list, cx).len(), 1);
1641        assert_eq!(cx.shown_system_notifications().len(), 1);
1642
1643        cx.simulate_system_notification_response(SystemNotificationResponse {
1644            tag: NotificationId::Id(TypeId::of::<FooKind>()).system_tag(),
1645            action_id: None,
1646        });
1647        cx.run_until_parked();
1648
1649        assert!(clicked.get(), "on_click should fire on system response");
1650        flush_dismiss(cx);
1651        assert!(
1652            ids(&list, cx).is_empty(),
1653            "the in-app counterpart should be closed"
1654        );
1655    }
1656
1657    #[gpui::test]
1658    fn explicit_close_retracts_system_notification_but_autohide_does_not(cx: &mut TestAppContext) {
1659        cx.update(|cx| {
1660            cx.set_global(Theme::default());
1661            init(cx);
1662            cx.set_app_identity("com.example.test", "Test");
1663        });
1664        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1665            list: cx.new(|cx| NotificationList::new(window, cx)),
1666            other_focus: cx.focus_handle(),
1667        });
1668        cx.update(|window, _| window.activate_window());
1669        let list = root.read_with(cx, |r, _| r.list.clone());
1670
1671        // Autohide expiry leaves the system notification in the center.
1672        list.update_in(cx, |list, window, cx| {
1673            list.push(
1674                Notification::info("auto")
1675                    .id::<FooKind>()
1676                    .delivery(NotificationDelivery::InAppAndSystem),
1677                window,
1678                cx,
1679            );
1680        });
1681        cx.run_until_parked();
1682        cx.background_executor.advance_clock(Duration::from_secs(6));
1683        cx.run_until_parked();
1684        flush_dismiss(cx);
1685        assert!(
1686            ids(&list, cx).is_empty(),
1687            "the toast should have autohidden"
1688        );
1689        assert!(
1690            cx.dismissed_system_notifications().is_empty(),
1691            "autohide must not retract the system notification"
1692        );
1693
1694        // An explicit close retracts it.
1695        list.update_in(cx, |list, window, cx| {
1696            list.push(
1697                Notification::info("manual")
1698                    .id::<BarKind>()
1699                    .delivery(NotificationDelivery::InAppAndSystem)
1700                    .autohide(false),
1701                window,
1702                cx,
1703            );
1704        });
1705        cx.run_until_parked();
1706        list.update_in(cx, |list, window, cx| {
1707            list.close(TypeId::of::<BarKind>(), window, cx);
1708        });
1709        flush_dismiss(cx);
1710        assert_eq!(
1711            cx.dismissed_system_notifications(),
1712            vec![NotificationId::Id(TypeId::of::<BarKind>()).system_tag()]
1713        );
1714    }
1715}