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 .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 this.top(px(0.) + y_offset)
527 .opacity(opacity)
528 .shadow(toast_shadow(opacity.powi(3)))
529 }
530 },
531 )
532 }
533}
534
535#[derive(Debug, Clone)]
537pub struct NotificationSettings {
538 pub placement: Anchor,
542 pub margins: Edges<Pixels>,
544 pub max_items: usize,
546 pub width: Pixels,
548 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, 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
572pub(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#[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 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 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 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 cx.dismiss_system_notification(&response.tag);
667 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 let _ = entry.window.update(cx, |_, window, cx| {
680 window.activate_window();
681 if let Some(list) = entry.list.upgrade() {
682 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
693struct AnchorStack {
695 state: ToastStackState,
696 focus_handle: FocusHandle,
697}
698
699pub struct NotificationList {
701 pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
703 stacks: Vec<(Anchor, AnchorStack)>,
704 focus_handle: FocusHandle,
705 autohide_ids: HashSet<NotificationId>,
707 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 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 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 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 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 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(¬ification, 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 ¬ification,
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 fn push_system(notification: &Notification, window: &Window, cx: &mut Context<Self>) {
883 let (title, body) = match (¬ification.title, ¬ification.message) {
884 (Some(title), message) => (title.clone(), message.clone().unwrap_or_default()),
885 (None, Some(message)) => (message.clone(), SharedString::default()),
886 (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 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 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 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 assert_ne!(
1165 Notification::new().id.system_tag(),
1166 Notification::new().id.system_tag()
1167 );
1168 }
1169
1170 #[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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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}