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                            .ghost()
466                            .xsmall()
467                            .on_click(cx.listener(|this, _, window, cx| {
468                                cx.stop_propagation();
469                                this.dismiss(window, cx);
470                            })),
471                    ),
472            )
473            .when_some(self.on_click.clone(), |this, on_click| {
474                this.on_click(cx.listener(move |view, event, window, cx| {
475                    view.dismiss(window, cx);
476                    on_click(event, window, cx);
477                }))
478            })
479            .on_aux_click(cx.listener(move |view, event: &ClickEvent, window, cx| {
480                if event.is_middle_click() {
481                    view.dismiss(window, cx);
482                }
483            }))
484            .with_animation(
485                ElementId::NamedInteger("slide-down".into(), closing as u64),
486                Animation::new(if closing {
487                    NOTIFICATION_EXIT_DURATION
488                } else {
489                    NOTIFICATION_TRANSITION_DURATION
490                })
491                .with_easing(cubic_bezier(0.25, 0.1, 0.25, 1.)),
492                move |this, delta| {
493                    if closing {
494                        let opacity = 1. - delta;
495                        let that = this.opacity(opacity).shadow(toast_shadow(opacity.powi(3)));
496                        let y_offset = match placement {
497                            Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
498                                -delta * NOTIFICATION_TRANSITION_OFFSET
499                            }
500                            Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
501                                delta * NOTIFICATION_TRANSITION_OFFSET
502                            }
503                            _ => px(0.),
504                        };
505                        that.top(y_offset)
506                    } else {
507                        let y_offset = match placement {
508                            Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
509                                -NOTIFICATION_TRANSITION_OFFSET
510                                    + delta * NOTIFICATION_TRANSITION_OFFSET
511                            }
512                            Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
513                                NOTIFICATION_TRANSITION_OFFSET
514                                    - delta * NOTIFICATION_TRANSITION_OFFSET
515                            }
516                            _ => px(0.),
517                        };
518                        let opacity = delta;
519                        // GPUI paints a drop shadow *under* the card and has no
520                        // group compositing, so a card that is still translucent
521                        // does not hide its own shadow and it shows through as a
522                        // dark slab. Ramping the ink by the cube of the fade
523                        // keeps it out of sight until the card can cover it —
524                        // where this used to drop the shadow outright past a
525                        // threshold, which popped.
526                        this.top(px(0.) + y_offset)
527                            .opacity(opacity)
528                            .shadow(toast_shadow(opacity.powi(3)))
529                    }
530                },
531            )
532    }
533}
534
535/// The settings for notifications.
536#[derive(Debug, Clone)]
537pub struct NotificationSettings {
538    /// The placement of the notifications, default: [`Anchor::TopRight`]
539    ///
540    /// A single notification can override this with [`Notification::placement`].
541    pub placement: Anchor,
542    /// The margins of the notification with respect to the window edges.
543    pub margins: Edges<Pixels>,
544    /// The maximum number of notifications to show at once, default: 10
545    pub max_items: usize,
546    /// The width of the notifications, default: 382px
547    pub width: Pixels,
548    /// Where notifications are delivered, default: [`NotificationDelivery::InApp`]
549    ///
550    /// A single notification can override this with [`Notification::delivery`].
551    pub delivery: NotificationDelivery,
552}
553
554impl Default for NotificationSettings {
555    fn default() -> Self {
556        let offset = px(16.);
557        Self {
558            placement: Anchor::TopRight,
559            margins: Edges {
560                top: TITLE_BAR_HEIGHT + offset, // avoid overlap with title bar
561                right: offset,
562                bottom: offset,
563                left: offset,
564            },
565            max_items: 10,
566            width: DEFAULT_NOTIFICATION_WIDTH,
567            delivery: NotificationDelivery::default(),
568        }
569    }
570}
571
572/// Registers the app-global system-notification response handler.
573///
574/// gpui keeps a single such handler (later registrations replace earlier
575/// ones), so gpui-component owns it: applications must not call
576/// [`gpui::App::on_system_notification_response`] after `gpui_component::init`.
577/// Responses to notifications the application posts itself are ignored here.
578pub(crate) fn init(cx: &mut App) {
579    cx.on_system_notification_response(SystemNotificationRegistry::handle_response);
580}
581
582struct SystemNotificationEntry {
583    window: AnyWindowHandle,
584    list: WeakEntity<NotificationList>,
585    id: NotificationId,
586    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
587}
588
589const MAX_SYSTEM_NOTIFICATION_ENTRIES: usize = 100;
590
591/// App-global map from system-notification tag to the state needed to
592/// dispatch the user's response, ordered oldest-first so overflow prunes the
593/// stalest entries.
594#[derive(Default)]
595struct SystemNotificationRegistry {
596    entries: Vec<(SharedString, SystemNotificationEntry)>,
597}
598
599impl Global for SystemNotificationRegistry {}
600
601impl SystemNotificationRegistry {
602    fn insert(cx: &mut App, tag: SharedString, entry: SystemNotificationEntry) {
603        let entries = &mut cx.default_global::<Self>().entries;
604        entries.retain(|(existing, _)| *existing != tag);
605        entries.push((tag, entry));
606        if entries.len() > MAX_SYSTEM_NOTIFICATION_ENTRIES {
607            let overflow = entries.len() - MAX_SYSTEM_NOTIFICATION_ENTRIES;
608            entries.drain(..overflow);
609        }
610    }
611
612    /// Retract the system notification for `id`, but only if it was posted
613    /// from the given window: another window pushing the same id owns the tag
614    /// now, and this window's local dismiss must not clobber it.
615    fn dismiss(cx: &mut App, id: &NotificationId, window_id: WindowId) {
616        let tag = id.system_tag();
617        let entries = &mut cx.default_global::<Self>().entries;
618        let Some(ix) = entries.iter().position(|(existing, entry)| {
619            *existing == tag && entry.window.window_id() == window_id
620        }) else {
621            return;
622        };
623        entries.remove(ix);
624        cx.dismiss_system_notification(&tag);
625    }
626
627    /// Retract all of the window's system notifications whose id matches the
628    /// given [`TypeId`], regardless of the [`NotificationId`] variant.
629    fn dismiss_by_type(cx: &mut App, type_id: TypeId, window_id: WindowId) {
630        Self::dismiss_matching(cx, window_id, |id| match id {
631            NotificationId::Id(t) | NotificationId::IdAndElementId(t, _) => *t == type_id,
632        });
633    }
634
635    /// Retract all system notifications posted from the given window.
636    fn dismiss_all(cx: &mut App, window_id: WindowId) {
637        Self::dismiss_matching(cx, window_id, |_| true);
638    }
639
640    fn dismiss_matching(
641        cx: &mut App,
642        window_id: WindowId,
643        matches: impl Fn(&NotificationId) -> bool,
644    ) {
645        let entries = &mut cx.default_global::<Self>().entries;
646        let mut tags = Vec::new();
647        entries.retain(|(tag, entry)| {
648            if entry.window.window_id() == window_id && matches(&entry.id) {
649                tags.push(tag.clone());
650                false
651            } else {
652                true
653            }
654        });
655        for tag in tags {
656            cx.dismiss_system_notification(&tag);
657        }
658    }
659
660    fn handle_response(response: SystemNotificationResponse, cx: &mut App) {
661        if !response.tag.starts_with(SYSTEM_TAG_PREFIX) {
662            return;
663        }
664        // Platforms usually remove a clicked notification themselves; retract
665        // explicitly for the ones that keep it in the notification center.
666        cx.dismiss_system_notification(&response.tag);
667        // The prefix already proves the notification is ours, so activate even
668        // when nothing is left to dispatch to: the platform can deliver a
669        // response for a notification posted before the process restarted, and
670        // entries are pruned past `MAX_SYSTEM_NOTIFICATION_ENTRIES`.
671        cx.activate(true);
672        let entries = &mut cx.default_global::<Self>().entries;
673        let Some(ix) = entries.iter().position(|(tag, _)| *tag == response.tag) else {
674            return;
675        };
676        let (_, entry) = entries.remove(ix);
677        // Errs when the window is already closed: the entry is removed and the
678        // application is still brought to the foreground.
679        let _ = entry.window.update(cx, |_, window, cx| {
680            window.activate_window();
681            if let Some(list) = entry.list.upgrade() {
682                // No-op when the toast already closed or was never created
683                // (system-only delivery).
684                list.update(cx, |list, cx| list.close(entry.id.clone(), window, cx));
685            }
686            if let Some(on_click) = entry.on_click {
687                on_click(&ClickEvent::default(), window, cx);
688            }
689        });
690    }
691}
692
693/// Per-placement stack state, created lazily for placements in use.
694struct AnchorStack {
695    state: ToastStackState,
696    focus_handle: FocusHandle,
697}
698
699/// A list of notifications.
700pub struct NotificationList {
701    /// Notifications that will be auto hidden.
702    pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
703    stacks: Vec<(Anchor, AnchorStack)>,
704    focus_handle: FocusHandle,
705    /// Mounted notifications that hide themselves after a timeout.
706    autohide_ids: HashSet<NotificationId>,
707    /// Whether the lifecycle clock is running. The loop clears it as it exits.
708    is_advancing: bool,
709    _subscriptions: HashMap<NotificationId, Subscription>,
710}
711
712impl NotificationList {
713    pub fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
714        Self {
715            notifications: ToastManager::new(ToastMotion::sonner()),
716            stacks: Vec::new(),
717            focus_handle: cx.focus_handle().tab_stop(true),
718            autohide_ids: HashSet::new(),
719            is_advancing: false,
720            _subscriptions: HashMap::new(),
721        }
722    }
723
724    /// Tick the toast lifecycle while a notification still has time to track.
725    ///
726    /// It advances the transition phases and samples the pause input (stack
727    /// expansion) that reaches the list through no event.
728    /// With nothing mounted, or only persistent notifications at rest, there is
729    /// nothing to do, so the window arms no timer until a push or close.
730    fn start_advancing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
731        if self.is_advancing {
732            return;
733        }
734        self.is_advancing = true;
735        // Detached rather than kept as a `Task`: the loop ends itself, and a
736        // handle on the list would have to be dropped from inside its own future.
737        cx.spawn_in(window, async move |view, cx| {
738            loop {
739                cx.background_executor()
740                    .timer(NOTIFICATION_ADVANCE_INTERVAL)
741                    .await;
742                let running = view.update_in(cx, |view, window, cx| {
743                    view.advance(window, cx);
744                    view.is_advancing = view.needs_clock();
745                    view.is_advancing
746                });
747                if !matches!(running, Ok(true)) {
748                    break;
749                }
750            }
751        })
752        .detach();
753    }
754
755    /// Whether a mounted notification is in transition or counting down to
756    /// autohide. A paused countdown still needs its last sample kept current.
757    fn needs_clock(&self) -> bool {
758        self.notifications
759            .iter()
760            .any(|(id, _, status)| match status {
761                ToastTransitionStatus::Present => self.autohide_ids.contains(id),
762                ToastTransitionStatus::Starting | ToastTransitionStatus::Ending => true,
763            })
764    }
765
766    fn is_expanded(&self) -> bool {
767        self.stacks
768            .iter()
769            .any(|(_, stack)| stack.state.is_expanded())
770    }
771
772    /// Group the visible notifications by their effective placement, pairing
773    /// each group with the element id its stack renders under and preserving
774    /// the display order within each group.
775    fn grouped(
776        &self,
777        cx: &App,
778    ) -> Vec<(
779        Anchor,
780        ElementId,
781        Vec<(NotificationId, Entity<Notification>)>,
782    )> {
783        let settings = &cx.theme().notification;
784        let (max_items, placement) = (settings.max_items, settings.placement);
785
786        let mut groups: Vec<(Anchor, Vec<(NotificationId, Entity<Notification>)>)> = Vec::new();
787        for (id, item, _) in self.notifications.visible(max_items) {
788            let anchor = item.read(cx).placement.unwrap_or(placement);
789            match groups.iter_mut().find(|(a, _)| *a == anchor) {
790                Some((_, items)) => items.push((id.clone(), item.clone())),
791                None => groups.push((anchor, vec![(id.clone(), item.clone())])),
792            }
793        }
794        groups
795            .into_iter()
796            .map(|(anchor, items)| (anchor, Self::stack_id(anchor), items))
797            .collect()
798    }
799
800    /// The element id a placement's stack renders under.
801    ///
802    /// Keyed by the placement itself rather than by position among the mounted
803    /// stacks: a stack whose id changes loses the element state of its whole
804    /// subtree, which replays the enter animation of every notification in it.
805    fn stack_id(anchor: Anchor) -> ElementId {
806        let ix = match anchor {
807            Anchor::TopLeft => 0,
808            Anchor::TopCenter => 1,
809            Anchor::TopRight => 2,
810            Anchor::BottomLeft => 3,
811            Anchor::BottomCenter => 4,
812            Anchor::BottomRight => 5,
813            Anchor::LeftCenter => 6,
814            Anchor::RightCenter => 7,
815        };
816        ("notification-list", ix as usize).into()
817    }
818
819    pub fn push(
820        &mut self,
821        notification: impl Into<Notification>,
822        window: &mut Window,
823        cx: &mut Context<Self>,
824    ) {
825        let notification = notification.into();
826        let delivery = notification
827            .delivery
828            .unwrap_or(cx.theme().notification.delivery);
829        if delivery.includes_system() {
830            Self::push_system(&notification, window, cx);
831        }
832        if !delivery.includes_in_app() {
833            return;
834        }
835
836        let id = notification.id.clone();
837        let autohide = notification.autohide;
838        let window_id = window.window_handle().window_id();
839
840        let notification = cx.new(|_| notification);
841
842        let dismiss_id = id.clone();
843        self._subscriptions.insert(
844            id.clone(),
845            cx.subscribe_in(
846                &notification,
847                window,
848                move |view, _, _: &DismissRequest, window, cx| {
849                    if view
850                        .notifications
851                        .dismiss(&dismiss_id, cx.background_executor().now())
852                    {
853                        if let Some(note) = view.notifications.get(&dismiss_id) {
854                            note.update(cx, |note, cx| note.begin_close(cx));
855                        }
856                        SystemNotificationRegistry::dismiss(cx, &dismiss_id, window_id);
857                        view.start_advancing(window, cx);
858                    }
859                },
860            ),
861        );
862        if autohide {
863            self.autohide_ids.insert(id.clone());
864        } else {
865            self.autohide_ids.remove(&id);
866        }
867
868        self.notifications.push(
869            id,
870            notification,
871            ToastOptions {
872                timeout: autohide.then_some(Duration::from_secs(5)),
873            },
874            cx.background_executor().now(),
875        );
876        self.start_advancing(window, cx);
877        cx.notify();
878    }
879
880    /// Post the notification to the OS notification center and register the
881    /// state needed to dispatch the user's response back to this window.
882    fn push_system(notification: &Notification, window: &Window, cx: &mut Context<Self>) {
883        let (title, body) = match (&notification.title, &notification.message) {
884            (Some(title), message) => (title.clone(), message.clone().unwrap_or_default()),
885            (None, Some(message)) => (message.clone(), SharedString::default()),
886            // A content-only notification has nothing textual to show.
887            (None, None) => return,
888        };
889        let tag = notification.id.system_tag();
890        let entry = SystemNotificationEntry {
891            window: window.window_handle(),
892            list: cx.entity().downgrade(),
893            id: notification.id.clone(),
894            on_click: notification.on_click.clone(),
895        };
896        SystemNotificationRegistry::insert(cx, tag.clone(), entry);
897        cx.show_system_notification(SystemNotification {
898            tag,
899            title,
900            body,
901            actions: Vec::new(),
902        });
903    }
904
905    fn advance(&mut self, window: &mut Window, cx: &mut Context<Self>) {
906        let changes = self
907            .notifications
908            .advance(cx.background_executor().now(), self.is_expanded());
909        for id in changes.presented {
910            if let Some(note) = self.notifications.get(&id) {
911                note.update(cx, |note, cx| note.complete_enter(cx));
912            }
913        }
914        for id in changes.ending {
915            if let Some(note) = self.notifications.get(&id) {
916                note.update(cx, |note, cx| note.begin_close(cx));
917            }
918        }
919        for (id, note) in changes.removed {
920            self._subscriptions.remove(&id);
921            self.autohide_ids.remove(&id);
922            note.update(cx, |note, cx| note.complete_close(window, cx));
923        }
924        if changes.changed {
925            cx.notify();
926        }
927    }
928
929    pub(crate) fn close(
930        &mut self,
931        id: impl Into<NotificationId>,
932        window: &mut Window,
933        cx: &mut Context<Self>,
934    ) {
935        let id: NotificationId = id.into();
936        // Unconditional: a system-only notification has no toast to dismiss,
937        // but its system counterpart must still be retracted.
938        SystemNotificationRegistry::dismiss(cx, &id, window.window_handle().window_id());
939        if self
940            .notifications
941            .dismiss(&id, cx.background_executor().now())
942        {
943            if let Some(n) = self.notifications.get(&id) {
944                n.update(cx, |note, cx| note.begin_close(cx))
945            }
946            self.start_advancing(window, cx);
947        }
948        cx.notify();
949    }
950
951    /// Close all notifications whose id matches the given [`TypeId`], regardless of
952    /// whether they were registered via [`Notification::id`] or [`Notification::id1`].
953    pub(crate) fn close_by_type(
954        &mut self,
955        type_id: TypeId,
956        window: &mut Window,
957        cx: &mut Context<Self>,
958    ) {
959        SystemNotificationRegistry::dismiss_by_type(
960            cx,
961            type_id,
962            window.window_handle().window_id(),
963        );
964        let matched: Vec<_> = self
965            .notifications
966            .iter()
967            .filter(|(id, _, _)| match id {
968                NotificationId::Id(t) | NotificationId::IdAndElementId(t, _) => *t == type_id,
969            })
970            .map(|(_, notification, _)| notification)
971            .cloned()
972            .collect();
973        for n in matched {
974            let id = n.read(cx).id.clone();
975            if self
976                .notifications
977                .dismiss(&id, cx.background_executor().now())
978            {
979                n.update(cx, |note, cx| note.begin_close(cx));
980                self.start_advancing(window, cx);
981            }
982        }
983        cx.notify();
984    }
985
986    pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
987        SystemNotificationRegistry::dismiss_all(cx, window.window_handle().window_id());
988        for id in self
989            .notifications
990            .dismiss_all(cx.background_executor().now())
991        {
992            if let Some(note) = self.notifications.get(&id) {
993                note.update(cx, |note, cx| note.begin_close(cx));
994            }
995            self.start_advancing(window, cx);
996        }
997        cx.notify();
998    }
999
1000    pub fn notifications(&self) -> Vec<Entity<Notification>> {
1001        self.notifications
1002            .iter()
1003            .map(|(_, value, _)| value.clone())
1004            .collect()
1005    }
1006}
1007
1008impl Render for NotificationList {
1009    fn render(
1010        &mut self,
1011        window: &mut gpui::Window,
1012        cx: &mut gpui::Context<Self>,
1013    ) -> impl IntoElement {
1014        let size = window.viewport_size();
1015        let settings = &cx.theme().notification;
1016        let (placement, margins, width) =
1017            (settings.placement, settings.margins.clone(), settings.width);
1018
1019        let groups = self.grouped(cx);
1020        self.stacks
1021            .retain(|(anchor, _)| groups.iter().any(|(a, _, _)| a == anchor));
1022
1023        let default_focus_handle = self.focus_handle.clone();
1024        let stacks = groups.into_iter().map(|(anchor, stack_id, items)| {
1025            let stack_ix = self
1026                .stacks
1027                .iter()
1028                .position(|(a, _)| *a == anchor)
1029                .unwrap_or_else(|| {
1030                    self.stacks.push((
1031                        anchor,
1032                        AnchorStack {
1033                            state: ToastStackState::default(),
1034                            focus_handle: if anchor == placement {
1035                                default_focus_handle.clone()
1036                            } else {
1037                                cx.focus_handle().tab_stop(true)
1038                            },
1039                        },
1040                    ));
1041                    self.stacks.len() - 1
1042                });
1043            let stack = &self.stacks[stack_ix].1;
1044
1045            items
1046                .into_iter()
1047                .fold(
1048                    ToastStack::new(stack_id, stack.state.clone()),
1049                    |stack, (id, item)| stack.item(format!("{id:?}"), item),
1050                )
1051                .placement(anchor)
1052                .focus_handle(stack.focus_handle.clone())
1053                .v_flex()
1054                .w(width)
1055                .max_h(size.height)
1056                .absolute()
1057                .map(|this| match anchor {
1058                    Anchor::TopLeft => this.top(margins.top).left(margins.left),
1059                    Anchor::TopRight => this.top(margins.top).right(margins.right),
1060                    Anchor::TopCenter => this.top(margins.top).left(relative(0.5)).ml(-width / 2.),
1061                    Anchor::BottomLeft => this.bottom(margins.bottom).left(margins.left),
1062                    Anchor::BottomRight => this.bottom(margins.bottom).right(margins.right),
1063                    Anchor::BottomCenter => this
1064                        .bottom(margins.bottom)
1065                        .left(relative(0.5))
1066                        .ml(-width / 2.),
1067                    Anchor::LeftCenter => this.left(margins.left).top_0().bottom_0().my_auto(),
1068                    Anchor::RightCenter => this.right(margins.right).top_0().bottom_0().my_auto(),
1069                })
1070        });
1071
1072        div().size_full().children(stacks)
1073    }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079    use crate::theme::Theme;
1080    use gpui::{TestAppContext, VisualTestContext};
1081
1082    struct FooKind;
1083    struct BarKind;
1084
1085    struct TestRoot {
1086        list: Entity<NotificationList>,
1087        other_focus: FocusHandle,
1088    }
1089
1090    impl Render for TestRoot {
1091        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1092            div()
1093                .child(self.list.clone())
1094                .child(div().track_focus(&self.other_focus))
1095        }
1096    }
1097
1098    fn ids(list: &Entity<NotificationList>, cx: &mut VisualTestContext) -> Vec<NotificationId> {
1099        list.read_with(cx, |l, cx| {
1100            l.notifications
1101                .iter()
1102                .map(|(_, n, _)| n.read(cx).id.clone())
1103                .collect()
1104        })
1105    }
1106
1107    /// Drive the dismiss animation timer + propagate the resulting `DismissEvent`
1108    /// so that closed notifications are removed from the list.
1109    fn flush_dismiss(cx: &mut VisualTestContext) {
1110        cx.background_executor
1111            .advance_clock(NOTIFICATION_EXIT_DURATION + Duration::from_millis(50));
1112        cx.run_until_parked();
1113    }
1114
1115    #[test]
1116    fn test_notification_builder() {
1117        let note = Notification::new()
1118            .title("title")
1119            .message("message")
1120            .with_type(NotificationType::Success)
1121            .placement(Anchor::BottomLeft)
1122            .delivery(NotificationDelivery::System)
1123            .autohide(false);
1124        assert_eq!(note.title, Some("title".into()));
1125        assert_eq!(note.message, Some("message".into()));
1126        assert!(matches!(note.type_, Some(NotificationType::Success)));
1127        assert_eq!(note.placement, Some(Anchor::BottomLeft));
1128        assert_eq!(note.delivery, Some(NotificationDelivery::System));
1129        assert!(!note.autohide);
1130
1131        assert_eq!(Notification::new().delivery, None);
1132        assert_eq!(
1133            Notification::new().system().delivery,
1134            Some(NotificationDelivery::System)
1135        );
1136        assert_eq!(
1137            Notification::new().in_app_and_system().delivery,
1138            Some(NotificationDelivery::InAppAndSystem)
1139        );
1140    }
1141
1142    #[test]
1143    fn notification_delivery_includes() {
1144        assert!(NotificationDelivery::InApp.includes_in_app());
1145        assert!(!NotificationDelivery::InApp.includes_system());
1146        assert!(!NotificationDelivery::System.includes_in_app());
1147        assert!(NotificationDelivery::System.includes_system());
1148        assert!(NotificationDelivery::InAppAndSystem.includes_in_app());
1149        assert!(NotificationDelivery::InAppAndSystem.includes_system());
1150    }
1151
1152    #[test]
1153    fn system_tag_is_stable_and_namespaced() {
1154        let by_type = NotificationId::from(TypeId::of::<FooKind>());
1155        assert_eq!(by_type.system_tag(), by_type.clone().system_tag());
1156        assert!(by_type.system_tag().starts_with(SYSTEM_TAG_PREFIX));
1157
1158        let key1 = NotificationId::from((TypeId::of::<FooKind>(), ElementId::from(1usize)));
1159        let key2 = NotificationId::from((TypeId::of::<FooKind>(), ElementId::from(2usize)));
1160        assert_ne!(key1.system_tag(), key2.system_tag());
1161        assert_ne!(by_type.system_tag(), key1.system_tag());
1162
1163        // Default (uuid) ids never collide, so they never replace each other.
1164        assert_ne!(
1165            Notification::new().id.system_tag(),
1166            Notification::new().id.system_tag()
1167        );
1168    }
1169
1170    /// A stack's element id must not depend on how many other placements are
1171    /// mounted: a changing id drops the element state of the whole subtree,
1172    /// which replays each notification's enter animation.
1173    #[gpui::test]
1174    fn stack_element_id_survives_other_placements_disappearing(cx: &mut TestAppContext) {
1175        cx.update(|cx| cx.set_global(Theme::default()));
1176        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1177            list: cx.new(|cx| NotificationList::new(window, cx)),
1178            other_focus: cx.focus_handle(),
1179        });
1180        cx.update(|window, _| window.activate_window());
1181        let list = root.read_with(cx, |root, _| root.list.clone());
1182
1183        // The left stack is pushed first, so it owns the lower group index.
1184        list.update_in(cx, |list, window, cx| {
1185            list.push(
1186                Notification::info("left")
1187                    .id::<FooKind>()
1188                    .placement(Anchor::BottomLeft)
1189                    .autohide(false),
1190                window,
1191                cx,
1192            );
1193            list.push(
1194                Notification::info("right").id::<BarKind>().autohide(false),
1195                window,
1196                cx,
1197            );
1198        });
1199
1200        let right_id = |cx: &mut VisualTestContext| {
1201            list.read_with(cx, |list, cx| {
1202                list.grouped(cx)
1203                    .into_iter()
1204                    .find(|(anchor, _, _)| *anchor == Anchor::TopRight)
1205                    .map(|(_, id, _)| id)
1206                    .expect("the right stack is mounted")
1207            })
1208        };
1209        let before = right_id(cx);
1210
1211        list.update_in(cx, |list, window, cx| {
1212            list.close(TypeId::of::<FooKind>(), window, cx);
1213        });
1214        flush_dismiss(cx);
1215
1216        assert_eq!(right_id(cx), before);
1217    }
1218
1219    #[gpui::test]
1220    fn per_notification_placement_stacks_by_anchor(cx: &mut TestAppContext) {
1221        cx.update(|cx| cx.set_global(Theme::default()));
1222        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1223            list: cx.new(|cx| NotificationList::new(window, cx)),
1224            other_focus: cx.focus_handle(),
1225        });
1226        cx.update(|window, _| window.activate_window());
1227        let list = root.read_with(cx, |root, _| root.list.clone());
1228
1229        list.update_in(cx, |list, window, cx| {
1230            list.push(
1231                Notification::info("default")
1232                    .id::<FooKind>()
1233                    .autohide(false),
1234                window,
1235                cx,
1236            );
1237            list.push(
1238                Notification::info("bottom")
1239                    .id::<BarKind>()
1240                    .placement(Anchor::BottomLeft)
1241                    .autohide(false),
1242                window,
1243                cx,
1244            );
1245        });
1246        cx.update(|window, cx| window.draw(cx).clear(cx));
1247
1248        let anchors =
1249            |list: &NotificationList| list.stacks.iter().map(|(a, _)| *a).collect::<Vec<_>>();
1250        assert_eq!(
1251            list.read_with(cx, |list, _| anchors(list)),
1252            [Anchor::TopRight, Anchor::BottomLeft]
1253        );
1254
1255        // Dismissing the overridden notification prunes its stack.
1256        list.update_in(cx, |list, window, cx| {
1257            list.close(TypeId::of::<BarKind>(), window, cx);
1258        });
1259        flush_dismiss(cx);
1260        cx.update(|window, cx| window.draw(cx).clear(cx));
1261        assert_eq!(
1262            list.read_with(cx, |list, _| anchors(list)),
1263            [Anchor::TopRight]
1264        );
1265    }
1266
1267    #[gpui::test]
1268    fn closing_toast_stays_mounted_until_its_transition_finishes(cx: &mut TestAppContext) {
1269        cx.update(|cx| cx.set_global(Theme::default()));
1270        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1271            list: cx.new(|cx| NotificationList::new(window, cx)),
1272            other_focus: cx.focus_handle(),
1273        });
1274        cx.update(|window, _| window.activate_window());
1275        let list = root.read_with(cx, |root, _| root.list.clone());
1276
1277        list.update_in(cx, |list, window, cx| {
1278            list.push(
1279                Notification::info("closing")
1280                    .id::<FooKind>()
1281                    .autohide(false),
1282                window,
1283                cx,
1284            );
1285            list.close(TypeId::of::<FooKind>(), window, cx);
1286        });
1287
1288        cx.background_executor
1289            .advance_clock(NOTIFICATION_EXIT_DURATION - Duration::from_millis(1));
1290        cx.run_until_parked();
1291        assert_eq!(ids(&list, cx).len(), 1);
1292
1293        cx.background_executor
1294            .advance_clock(Duration::from_millis(1));
1295        cx.run_until_parked();
1296        assert!(ids(&list, cx).is_empty());
1297    }
1298
1299    #[gpui::test]
1300    fn lifecycle_clock_runs_only_while_a_notification_is_mounted(cx: &mut TestAppContext) {
1301        cx.update(|cx| cx.set_global(Theme::default()));
1302        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1303            list: cx.new(|cx| NotificationList::new(window, cx)),
1304            other_focus: cx.focus_handle(),
1305        });
1306        cx.update(|window, _| window.activate_window());
1307        let list = root.read_with(cx, |root, _| root.list.clone());
1308
1309        // A list that has never shown anything arms no timer.
1310        cx.background_executor.advance_clock(Duration::from_secs(1));
1311        cx.run_until_parked();
1312        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1313
1314        list.update_in(cx, |list, window, cx| {
1315            list.push(Notification::info("tick").id::<FooKind>(), window, cx);
1316        });
1317        assert!(list.read_with(cx, |list, _| list.is_advancing));
1318
1319        // Autohide expiry plus the exit transition stops the clock with it.
1320        cx.background_executor
1321            .advance_clock(NOTIFICATION_TRANSITION_DURATION + Duration::from_secs(5));
1322        cx.run_until_parked();
1323        flush_dismiss(cx);
1324        assert!(ids(&list, cx).is_empty());
1325        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1326
1327        // A later notification restarts it.
1328        list.update_in(cx, |list, window, cx| {
1329            list.push(Notification::info("again").id::<BarKind>(), window, cx);
1330        });
1331        assert!(list.read_with(cx, |list, _| list.is_advancing));
1332        cx.background_executor
1333            .advance_clock(NOTIFICATION_TRANSITION_DURATION + Duration::from_secs(5));
1334        cx.run_until_parked();
1335        flush_dismiss(cx);
1336        assert!(ids(&list, cx).is_empty());
1337        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1338    }
1339
1340    #[gpui::test]
1341    fn lifecycle_clock_rests_while_only_persistent_notifications_are_shown(
1342        cx: &mut TestAppContext,
1343    ) {
1344        cx.update(|cx| cx.set_global(Theme::default()));
1345        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1346            list: cx.new(|cx| NotificationList::new(window, cx)),
1347            other_focus: cx.focus_handle(),
1348        });
1349        cx.update(|window, _| window.activate_window());
1350        let list = root.read_with(cx, |root, _| root.list.clone());
1351
1352        list.update_in(cx, |list, window, cx| {
1353            list.push(
1354                Notification::info("first").id::<FooKind>().autohide(false),
1355                window,
1356                cx,
1357            );
1358            list.push(
1359                Notification::info("second").id::<BarKind>().autohide(false),
1360                window,
1361                cx,
1362            );
1363        });
1364        assert!(list.read_with(cx, |list, _| list.is_advancing));
1365
1366        // Once both have entered, nothing is left to time.
1367        cx.background_executor
1368            .advance_clock(NOTIFICATION_TRANSITION_DURATION + NOTIFICATION_ADVANCE_INTERVAL);
1369        cx.run_until_parked();
1370        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1371        assert_eq!(ids(&list, cx).len(), 2);
1372
1373        // A programmatic close restarts the clock to finish the exit.
1374        list.update_in(cx, |list, window, cx| {
1375            list.close(TypeId::of::<FooKind>(), window, cx);
1376        });
1377        assert!(list.read_with(cx, |list, _| list.is_advancing));
1378        flush_dismiss(cx);
1379        assert_eq!(
1380            ids(&list, cx),
1381            [NotificationId::from(TypeId::of::<BarKind>())]
1382        );
1383        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1384
1385        // So does a dismiss requested by the notification itself.
1386        let note = list.read_with(cx, |list, _| list.notifications()[0].clone());
1387        note.update_in(cx, |note, window, cx| note.dismiss(window, cx));
1388        cx.run_until_parked();
1389        assert!(list.read_with(cx, |list, _| list.is_advancing));
1390        flush_dismiss(cx);
1391        assert!(ids(&list, cx).is_empty());
1392        assert!(!list.read_with(cx, |list, _| list.is_advancing));
1393    }
1394
1395    #[gpui::test]
1396    fn inactive_window_does_not_pause_autohide(cx: &mut TestAppContext) {
1397        cx.update(|cx| cx.set_global(Theme::default()));
1398        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1399            list: cx.new(|cx| NotificationList::new(window, cx)),
1400            other_focus: cx.focus_handle(),
1401        });
1402        let list = root.read_with(cx, |root, _| root.list.clone());
1403        list.update_in(cx, |list, window, cx| {
1404            assert!(!window.is_window_active());
1405            list.push(Notification::info("background").id::<FooKind>(), window, cx);
1406        });
1407
1408        cx.background_executor
1409            .advance_clock(NOTIFICATION_TRANSITION_DURATION);
1410        cx.run_until_parked();
1411        let note = list.read_with(cx, |list, _| {
1412            list.notifications
1413                .get(&NotificationId::from(TypeId::of::<FooKind>()))
1414                .unwrap()
1415                .clone()
1416        });
1417        assert_eq!(
1418            note.read_with(cx, |note, _| note.transition_status),
1419            ToastTransitionStatus::Present
1420        );
1421
1422        cx.background_executor
1423            .advance_clock(Duration::from_secs(5) + Duration::from_millis(50));
1424        cx.run_until_parked();
1425        assert_eq!(
1426            note.read_with(cx, |note, _| note.transition_status),
1427            ToastTransitionStatus::Ending
1428        );
1429    }
1430
1431    #[gpui::test]
1432    fn focus_pauses_autohide_and_present_phase_is_projected(cx: &mut TestAppContext) {
1433        cx.update(|cx| cx.set_global(Theme::default()));
1434        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1435            list: cx.new(|cx| NotificationList::new(window, cx)),
1436            other_focus: cx.focus_handle(),
1437        });
1438        let list = root.read_with(cx, |root, _| root.list.clone());
1439        list.update_in(cx, |list, window, cx| {
1440            list.push(Notification::info("paused").id::<FooKind>(), window, cx);
1441        });
1442
1443        cx.background_executor
1444            .advance_clock(NOTIFICATION_TRANSITION_DURATION);
1445        cx.run_until_parked();
1446        let note = list.read_with(cx, |list, _| {
1447            list.notifications
1448                .get(&NotificationId::from(TypeId::of::<FooKind>()))
1449                .unwrap()
1450                .clone()
1451        });
1452        assert_eq!(
1453            note.read_with(cx, |note, _| note.transition_status),
1454            ToastTransitionStatus::Present
1455        );
1456
1457        let list_focus = list.read_with(cx, |list, _| list.focus_handle.clone());
1458        cx.update(|window, cx| {
1459            list_focus.focus(window, cx);
1460            window.draw(cx).clear(cx);
1461        });
1462        cx.background_executor.advance_clock(Duration::from_secs(5));
1463        cx.run_until_parked();
1464        assert_eq!(
1465            note.read_with(cx, |note, _| note.transition_status),
1466            ToastTransitionStatus::Present
1467        );
1468
1469        let other_focus = root.read_with(cx, |root, _| root.other_focus.clone());
1470        cx.update(|window, cx| {
1471            other_focus.focus(window, cx);
1472            window.draw(cx).clear(cx);
1473        });
1474        assert!(!list.read_with(cx, |list, _| list.is_expanded()));
1475        cx.background_executor
1476            .advance_clock(Duration::from_secs(5) + Duration::from_millis(50));
1477        cx.run_until_parked();
1478        assert_eq!(
1479            note.read_with(cx, |note, _| note.transition_status),
1480            ToastTransitionStatus::Ending
1481        );
1482    }
1483
1484    #[gpui::test]
1485    fn close_by_type_removes_id_and_all_id1_of_same_type(cx: &mut TestAppContext) {
1486        cx.update(|cx| cx.set_global(Theme::default()));
1487        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1488            list: cx.new(|cx| NotificationList::new(window, cx)),
1489            other_focus: cx.focus_handle(),
1490        });
1491        let list = root.read_with(cx, |r, _| r.list.clone());
1492
1493        list.update_in(cx, |list, window, cx| {
1494            list.push(
1495                Notification::info("plain").id::<FooKind>().autohide(false),
1496                window,
1497                cx,
1498            );
1499            list.push(
1500                Notification::info("a").id1::<FooKind>(1).autohide(false),
1501                window,
1502                cx,
1503            );
1504            list.push(
1505                Notification::info("b").id1::<FooKind>(2).autohide(false),
1506                window,
1507                cx,
1508            );
1509            list.push(
1510                Notification::info("bar").id::<BarKind>().autohide(false),
1511                window,
1512                cx,
1513            );
1514        });
1515        cx.run_until_parked();
1516        assert_eq!(ids(&list, cx).len(), 4);
1517
1518        list.update_in(cx, |list, window, cx| {
1519            list.close_by_type(TypeId::of::<FooKind>(), window, cx);
1520        });
1521        flush_dismiss(cx);
1522
1523        let remaining = ids(&list, cx);
1524        assert_eq!(
1525            remaining,
1526            vec![NotificationId::Id(TypeId::of::<BarKind>())],
1527            "only the BarKind notification should survive"
1528        );
1529    }
1530
1531    #[gpui::test]
1532    fn close_with_id_and_element_id_removes_only_matching_key(cx: &mut TestAppContext) {
1533        cx.update(|cx| cx.set_global(Theme::default()));
1534        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1535            list: cx.new(|cx| NotificationList::new(window, cx)),
1536            other_focus: cx.focus_handle(),
1537        });
1538        let list = root.read_with(cx, |r, _| r.list.clone());
1539
1540        list.update_in(cx, |list, window, cx| {
1541            list.push(
1542                Notification::info("a").id1::<FooKind>(1).autohide(false),
1543                window,
1544                cx,
1545            );
1546            list.push(
1547                Notification::info("b").id1::<FooKind>(2).autohide(false),
1548                window,
1549                cx,
1550            );
1551            list.push(
1552                Notification::info("plain").id::<FooKind>().autohide(false),
1553                window,
1554                cx,
1555            );
1556        });
1557
1558        list.update_in(cx, |list, window, cx| {
1559            list.close(
1560                (TypeId::of::<FooKind>(), ElementId::from(1usize)),
1561                window,
1562                cx,
1563            );
1564        });
1565        flush_dismiss(cx);
1566
1567        let remaining = ids(&list, cx);
1568        assert_eq!(remaining.len(), 2);
1569        assert!(remaining.contains(&NotificationId::IdAndElementId(
1570            TypeId::of::<FooKind>(),
1571            ElementId::from(2usize),
1572        )));
1573        assert!(remaining.contains(&NotificationId::Id(TypeId::of::<FooKind>())));
1574    }
1575
1576    #[gpui::test]
1577    fn close_with_only_type_id_does_not_match_id1_entries(cx: &mut TestAppContext) {
1578        // The plain `close(TypeId)` form (used by the legacy code path) must keep
1579        // its narrow semantics: it only matches `NotificationId::Id`, not
1580        // `NotificationId::IdAndElementId`. The new `close_by_type` is the broad form.
1581        cx.update(|cx| cx.set_global(Theme::default()));
1582        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1583            list: cx.new(|cx| NotificationList::new(window, cx)),
1584            other_focus: cx.focus_handle(),
1585        });
1586        let list = root.read_with(cx, |r, _| r.list.clone());
1587
1588        list.update_in(cx, |list, window, cx| {
1589            list.push(
1590                Notification::info("a").id1::<FooKind>(1).autohide(false),
1591                window,
1592                cx,
1593            );
1594        });
1595
1596        list.update_in(cx, |list, window, cx| {
1597            list.close(TypeId::of::<FooKind>(), window, cx);
1598        });
1599        flush_dismiss(cx);
1600
1601        assert_eq!(ids(&list, cx).len(), 1, "id1 entry should remain untouched");
1602    }
1603
1604    #[gpui::test]
1605    fn close_by_type_with_no_match_is_noop(cx: &mut TestAppContext) {
1606        cx.update(|cx| cx.set_global(Theme::default()));
1607        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1608            list: cx.new(|cx| NotificationList::new(window, cx)),
1609            other_focus: cx.focus_handle(),
1610        });
1611        let list = root.read_with(cx, |r, _| r.list.clone());
1612
1613        list.update_in(cx, |list, window, cx| {
1614            list.push(
1615                Notification::info("bar").id::<BarKind>().autohide(false),
1616                window,
1617                cx,
1618            );
1619        });
1620
1621        list.update_in(cx, |list, window, cx| {
1622            list.close_by_type(TypeId::of::<FooKind>(), window, cx);
1623        });
1624        flush_dismiss(cx);
1625
1626        assert_eq!(ids(&list, cx).len(), 1);
1627    }
1628
1629    /// Applications may post their own system notifications; a response for
1630    /// one of those must not be retracted or dispatched by the library.
1631    #[gpui::test]
1632    fn response_for_a_foreign_tag_is_ignored(cx: &mut TestAppContext) {
1633        cx.update(|cx| {
1634            init(cx);
1635            cx.set_app_identity("com.example.test", "Test");
1636        });
1637        cx.simulate_system_notification_response(SystemNotificationResponse {
1638            tag: "com.example.app/own-tag".into(),
1639            action_id: None,
1640        });
1641        cx.run_until_parked();
1642        assert!(cx.dismissed_system_notifications().is_empty());
1643    }
1644
1645    #[gpui::test]
1646    fn system_delivery_posts_to_center_without_in_app_toast(cx: &mut TestAppContext) {
1647        cx.update(|cx| {
1648            cx.set_global(Theme::default());
1649            init(cx);
1650            // The test platform drops posts until an identity is set,
1651            // mirroring Windows.
1652            cx.set_app_identity("com.example.test", "Test");
1653        });
1654        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1655            list: cx.new(|cx| NotificationList::new(window, cx)),
1656            other_focus: cx.focus_handle(),
1657        });
1658        let list = root.read_with(cx, |r, _| r.list.clone());
1659
1660        list.update_in(cx, |list, window, cx| {
1661            list.push(
1662                Notification::info("body text")
1663                    .title("Title")
1664                    .id::<FooKind>()
1665                    .delivery(NotificationDelivery::System),
1666                window,
1667                cx,
1668            );
1669            // Message-only: the message becomes the system title.
1670            list.push(
1671                Notification::info("message only")
1672                    .id::<BarKind>()
1673                    .delivery(NotificationDelivery::System),
1674                window,
1675                cx,
1676            );
1677        });
1678        cx.run_until_parked();
1679
1680        let shown = cx.shown_system_notifications();
1681        assert_eq!(shown.len(), 2);
1682        assert_eq!(
1683            shown[0].tag,
1684            NotificationId::Id(TypeId::of::<FooKind>()).system_tag()
1685        );
1686        assert_eq!(shown[0].title, "Title");
1687        assert_eq!(shown[0].body, "body text");
1688        assert_eq!(shown[1].title, "message only");
1689        assert_eq!(shown[1].body, "");
1690        assert!(ids(&list, cx).is_empty(), "no in-app toast should exist");
1691
1692        // System-only notifications are still retractable through the
1693        // regular close path even though no toast entity exists.
1694        list.update_in(cx, |list, window, cx| {
1695            list.close(TypeId::of::<FooKind>(), window, cx);
1696        });
1697        assert_eq!(
1698            cx.dismissed_system_notifications(),
1699            vec![NotificationId::Id(TypeId::of::<FooKind>()).system_tag()]
1700        );
1701    }
1702
1703    #[gpui::test]
1704    fn system_click_activates_window_closes_toast_and_fires_on_click(cx: &mut TestAppContext) {
1705        cx.update(|cx| {
1706            cx.set_global(Theme::default());
1707            init(cx);
1708            cx.set_app_identity("com.example.test", "Test");
1709        });
1710        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1711            list: cx.new(|cx| NotificationList::new(window, cx)),
1712            other_focus: cx.focus_handle(),
1713        });
1714        let list = root.read_with(cx, |r, _| r.list.clone());
1715
1716        let clicked = Rc::new(std::cell::Cell::new(false));
1717        let on_click_flag = clicked.clone();
1718        list.update_in(cx, |list, window, cx| {
1719            list.push(
1720                Notification::info("message")
1721                    .title("Title")
1722                    .id::<FooKind>()
1723                    .delivery(NotificationDelivery::InAppAndSystem)
1724                    .autohide(false)
1725                    .on_click(move |_, _, _| on_click_flag.set(true)),
1726                window,
1727                cx,
1728            );
1729        });
1730        cx.run_until_parked();
1731        assert_eq!(ids(&list, cx).len(), 1);
1732        assert_eq!(cx.shown_system_notifications().len(), 1);
1733
1734        cx.simulate_system_notification_response(SystemNotificationResponse {
1735            tag: NotificationId::Id(TypeId::of::<FooKind>()).system_tag(),
1736            action_id: None,
1737        });
1738        cx.run_until_parked();
1739
1740        assert!(clicked.get(), "on_click should fire on system response");
1741        flush_dismiss(cx);
1742        assert!(
1743            ids(&list, cx).is_empty(),
1744            "the in-app counterpart should be closed"
1745        );
1746    }
1747
1748    #[gpui::test]
1749    fn explicit_close_retracts_system_notification_but_autohide_does_not(cx: &mut TestAppContext) {
1750        cx.update(|cx| {
1751            cx.set_global(Theme::default());
1752            init(cx);
1753            cx.set_app_identity("com.example.test", "Test");
1754        });
1755        let (root, cx) = cx.add_window_view(|window, cx| TestRoot {
1756            list: cx.new(|cx| NotificationList::new(window, cx)),
1757            other_focus: cx.focus_handle(),
1758        });
1759        cx.update(|window, _| window.activate_window());
1760        let list = root.read_with(cx, |r, _| r.list.clone());
1761
1762        // Autohide expiry leaves the system notification in the center.
1763        list.update_in(cx, |list, window, cx| {
1764            list.push(
1765                Notification::info("auto")
1766                    .id::<FooKind>()
1767                    .delivery(NotificationDelivery::InAppAndSystem),
1768                window,
1769                cx,
1770            );
1771        });
1772        cx.run_until_parked();
1773        cx.background_executor.advance_clock(Duration::from_secs(6));
1774        cx.run_until_parked();
1775        flush_dismiss(cx);
1776        assert!(
1777            ids(&list, cx).is_empty(),
1778            "the toast should have autohidden"
1779        );
1780        assert!(
1781            cx.dismissed_system_notifications().is_empty(),
1782            "autohide must not retract the system notification"
1783        );
1784
1785        // An explicit close retracts it.
1786        list.update_in(cx, |list, window, cx| {
1787            list.push(
1788                Notification::info("manual")
1789                    .id::<BarKind>()
1790                    .delivery(NotificationDelivery::InAppAndSystem)
1791                    .autohide(false),
1792                window,
1793                cx,
1794            );
1795        });
1796        cx.run_until_parked();
1797        list.update_in(cx, |list, window, cx| {
1798            list.close(TypeId::of::<BarKind>(), window, cx);
1799        });
1800        flush_dismiss(cx);
1801        assert_eq!(
1802            cx.dismissed_system_notifications(),
1803            vec![NotificationId::Id(TypeId::of::<BarKind>()).system_tag()]
1804        );
1805    }
1806}