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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum NotificationDelivery {
61 #[default]
63 InApp,
64 System,
66 InAppAndSystem,
68}
69
70impl NotificationDelivery {
71 pub fn includes_in_app(&self) -> bool {
73 matches!(self, Self::InApp | Self::InAppAndSystem)
74 }
75
76 pub fn includes_system(&self) -> bool {
78 matches!(self, Self::System | Self::InAppAndSystem)
79 }
80}
81
82const 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 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
113pub struct Notification {
115 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 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 pub fn message(mut self, message: impl Into<SharedString>) -> Self {
198 self.message = Some(message.into());
199 self
200 }
201
202 pub fn info(message: impl Into<SharedString>) -> Self {
204 Self::new()
205 .message(message)
206 .with_type(NotificationType::Info)
207 }
208
209 pub fn success(message: impl Into<SharedString>) -> Self {
211 Self::new()
212 .message(message)
213 .with_type(NotificationType::Success)
214 }
215
216 pub fn warning(message: impl Into<SharedString>) -> Self {
218 Self::new()
219 .message(message)
220 .with_type(NotificationType::Warning)
221 }
222
223 pub fn error(message: impl Into<SharedString>) -> Self {
225 Self::new()
226 .message(message)
227 .with_type(NotificationType::Error)
228 }
229
230 pub fn id<T: Sized + 'static>(mut self) -> Self {
237 self.id = TypeId::of::<T>().into();
238 self
239 }
240
241 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 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
251 self.title = Some(title.into());
252 self
253 }
254
255 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
259 self.icon = Some(icon.into());
260 self
261 }
262
263 pub fn with_type(mut self, type_: NotificationType) -> Self {
265 self.type_ = Some(type_);
266 self
267 }
268
269 pub fn placement(mut self, placement: Anchor) -> Self {
274 self.placement = Some(placement);
275 self
276 }
277
278 pub fn delivery(mut self, delivery: NotificationDelivery) -> Self {
298 self.delivery = Some(delivery);
299 self
300 }
301
302 pub fn system(self) -> Self {
307 self.delivery(NotificationDelivery::System)
308 }
309
310 pub fn in_app_and_system(self) -> Self {
317 self.delivery(NotificationDelivery::InAppAndSystem)
318 }
319
320 pub fn autohide(mut self, autohide: bool) -> Self {
322 self.autohide = autohide;
323 self
324 }
325
326 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 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 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 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 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 this.top(px(0.) + y_offset)
528 .opacity(opacity)
529 .shadow(toast_shadow(opacity.powi(3)))
530 }
531 },
532 )
533 }
534}
535
536#[derive(Debug, Clone)]
538#[non_exhaustive]
539pub struct NotificationSettings {
540 pub placement: Anchor,
544 pub margins: Edges<Pixels>,
546 pub max_items: usize,
548 pub width: Pixels,
550 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, 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
574pub(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#[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 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 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 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 cx.dismiss_system_notification(&response.tag);
669 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 let _ = entry.window.update(cx, |_, window, cx| {
682 window.activate_window();
683 if let Some(list) = entry.list.upgrade() {
684 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
695struct AnchorStack {
697 state: ToastStackState,
698 focus_handle: FocusHandle,
699}
700
701pub struct NotificationList {
703 pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
705 stacks: Vec<(Anchor, AnchorStack)>,
706 focus_handle: FocusHandle,
707 autohide_ids: HashSet<NotificationId>,
709 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 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 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 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 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 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(¬ification, 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 ¬ification,
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 fn push_system(notification: &Notification, window: &Window, cx: &mut Context<Self>) {
885 let (title, body) = match (¬ification.title, ¬ification.message) {
886 (Some(title), message) => (title.clone(), message.clone().unwrap_or_default()),
887 (None, Some(message)) => (message.clone(), SharedString::default()),
888 (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 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 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 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 assert_ne!(
1167 Notification::new().id.system_tag(),
1168 Notification::new().id.system_tag()
1169 );
1170 }
1171
1172 #[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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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}