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