Skip to main content

gpui_component/
notification.rs

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