gpui_component/
notification.rs

1use std::{
2    any::TypeId,
3    collections::{HashMap, VecDeque},
4    rc::Rc,
5    time::Duration,
6};
7
8use gpui::{
9    div, prelude::FluentBuilder, px, Animation, AnimationExt, AnyElement, App, AppContext,
10    ClickEvent, Context, DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _,
11    IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement,
12    StyleRefinement, Styled, Subscription, Window,
13};
14use smol::Timer;
15
16use crate::{
17    animation::cubic_bezier,
18    button::{Button, ButtonVariants as _},
19    h_flex, v_flex, ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt,
20};
21
22#[derive(Debug, Clone, Copy, Default)]
23pub enum NotificationType {
24    #[default]
25    Info,
26    Success,
27    Warning,
28    Error,
29}
30
31impl NotificationType {
32    fn icon(&self, cx: &App) -> Icon {
33        match self {
34            Self::Info => Icon::new(IconName::Info).text_color(cx.theme().info),
35            Self::Success => Icon::new(IconName::CircleCheck).text_color(cx.theme().success),
36            Self::Warning => Icon::new(IconName::TriangleAlert).text_color(cx.theme().warning),
37            Self::Error => Icon::new(IconName::CircleX).text_color(cx.theme().danger),
38        }
39    }
40}
41
42#[derive(Debug, PartialEq, Clone, Hash, Eq)]
43pub(crate) enum NotificationId {
44    Id(TypeId),
45    IdAndElementId(TypeId, ElementId),
46}
47
48impl From<TypeId> for NotificationId {
49    fn from(type_id: TypeId) -> Self {
50        Self::Id(type_id)
51    }
52}
53
54impl From<(TypeId, ElementId)> for NotificationId {
55    fn from((type_id, id): (TypeId, ElementId)) -> Self {
56        Self::IdAndElementId(type_id, id)
57    }
58}
59
60/// A notification element.
61pub struct Notification {
62    /// The id is used make the notification unique.
63    /// Then you push a notification with the same id, the previous notification will be replaced.
64    ///
65    /// None means the notification will be added to the end of the list.
66    id: NotificationId,
67    style: StyleRefinement,
68    type_: Option<NotificationType>,
69    title: Option<SharedString>,
70    message: Option<SharedString>,
71    icon: Option<Icon>,
72    autohide: bool,
73    action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
74    content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
75    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
76    closing: bool,
77}
78
79impl From<String> for Notification {
80    fn from(s: String) -> Self {
81        Self::new().message(s)
82    }
83}
84
85impl From<SharedString> for Notification {
86    fn from(s: SharedString) -> Self {
87        Self::new().message(s)
88    }
89}
90
91impl From<&'static str> for Notification {
92    fn from(s: &'static str) -> Self {
93        Self::new().message(s)
94    }
95}
96
97impl From<(NotificationType, &'static str)> for Notification {
98    fn from((type_, content): (NotificationType, &'static str)) -> Self {
99        Self::new().message(content).with_type(type_)
100    }
101}
102
103impl From<(NotificationType, SharedString)> for Notification {
104    fn from((type_, content): (NotificationType, SharedString)) -> Self {
105        Self::new().message(content).with_type(type_)
106    }
107}
108
109struct DefaultIdType;
110
111impl Notification {
112    /// Create a new notification.
113    ///
114    /// The default id is a random UUID.
115    pub fn new() -> Self {
116        let id: SharedString = uuid::Uuid::new_v4().to_string().into();
117        let id = (TypeId::of::<DefaultIdType>(), id.into());
118
119        Self {
120            id: id.into(),
121            style: StyleRefinement::default(),
122            title: None,
123            message: None,
124            type_: None,
125            icon: None,
126            autohide: true,
127            action_builder: None,
128            content_builder: None,
129            on_click: None,
130            closing: false,
131        }
132    }
133
134    /// Set the message of the notification, default is None.
135    pub fn message(mut self, message: impl Into<SharedString>) -> Self {
136        self.message = Some(message.into());
137        self
138    }
139
140    /// Create an info notification with the given message.
141    pub fn info(message: impl Into<SharedString>) -> Self {
142        Self::new()
143            .message(message)
144            .with_type(NotificationType::Info)
145    }
146
147    /// Create a success notification with the given message.
148    pub fn success(message: impl Into<SharedString>) -> Self {
149        Self::new()
150            .message(message)
151            .with_type(NotificationType::Success)
152    }
153
154    /// Create a warning notification with the given message.
155    pub fn warning(message: impl Into<SharedString>) -> Self {
156        Self::new()
157            .message(message)
158            .with_type(NotificationType::Warning)
159    }
160
161    /// Create an error notification with the given message.
162    pub fn error(message: impl Into<SharedString>) -> Self {
163        Self::new()
164            .message(message)
165            .with_type(NotificationType::Error)
166    }
167
168    /// Set the type for unique identification of the notification.
169    ///
170    /// ```rs
171    /// struct MyNotificationKind;
172    /// let notification = Notification::new("Hello").id::<MyNotificationKind>();
173    /// ```
174    pub fn id<T: Sized + 'static>(mut self) -> Self {
175        self.id = TypeId::of::<T>().into();
176        self
177    }
178
179    /// Set the type and id of the notification, used to uniquely identify the notification.
180    pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
181        self.id = (TypeId::of::<T>(), key.into()).into();
182        self
183    }
184
185    /// Set the title of the notification, default is None.
186    ///
187    /// If title is None, the notification will not have a title.
188    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
189        self.title = Some(title.into());
190        self
191    }
192
193    /// Set the icon of the notification.
194    ///
195    /// If icon is None, the notification will use the default icon of the type.
196    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
197        self.icon = Some(icon.into());
198        self
199    }
200
201    /// Set the type of the notification, default is NotificationType::Info.
202    pub fn with_type(mut self, type_: NotificationType) -> Self {
203        self.type_ = Some(type_);
204        self
205    }
206
207    /// Set the auto hide of the notification, default is true.
208    pub fn autohide(mut self, autohide: bool) -> Self {
209        self.autohide = autohide;
210        self
211    }
212
213    /// Set the click callback of the notification.
214    pub fn on_click(
215        mut self,
216        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
217    ) -> Self {
218        self.on_click = Some(Rc::new(on_click));
219        self
220    }
221
222    /// Set the action button of the notification.
223    pub fn action<F>(mut self, action: F) -> Self
224    where
225        F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
226    {
227        self.action_builder = Some(Rc::new(action));
228        self
229    }
230
231    /// Dismiss the notification.
232    pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
233        if self.closing {
234            return;
235        }
236        self.closing = true;
237        cx.notify();
238
239        // Dismiss the notification after 0.15s to show the animation.
240        cx.spawn(async move |view, cx| {
241            Timer::after(Duration::from_secs_f32(0.15)).await;
242            cx.update(|cx| {
243                if let Some(view) = view.upgrade() {
244                    view.update(cx, |view, cx| {
245                        view.closing = false;
246                        cx.emit(DismissEvent);
247                    });
248                }
249            })
250        })
251        .detach()
252    }
253
254    /// Set the content of the notification.
255    pub fn content(
256        mut self,
257        content: impl Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement + 'static,
258    ) -> Self {
259        self.content_builder = Some(Rc::new(content));
260        self
261    }
262}
263impl EventEmitter<DismissEvent> for Notification {}
264impl FluentBuilder for Notification {}
265impl Styled for Notification {
266    fn style(&mut self) -> &mut StyleRefinement {
267        &mut self.style
268    }
269}
270impl Render for Notification {
271    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
272        let content = self.content_builder.clone().map(|builder| builder(self, window, cx));
273        let action = self.action_builder.clone().map(|builder| builder(self, window, cx).small().mr_3p5());
274
275        let closing = self.closing;
276        let icon = match self.type_ {
277            None => self.icon.clone(),
278            Some(type_) => Some(type_.icon(cx)),
279        };
280        let has_icon = icon.is_some();
281
282        h_flex()
283            .id("notification")
284            .group("")
285            .occlude()
286            .relative()
287            .w_112()
288            .border_1()
289            .border_color(cx.theme().border)
290            .bg(cx.theme().popover)
291            .rounded(cx.theme().radius_lg)
292            .shadow_md()
293            .py_3p5()
294            .px_4()
295            .gap_3()
296            .refine_style(&self.style)
297            .when_some(icon, |this, icon| {
298                this.child(div().absolute().py_3p5().left_4().child(icon))
299            })
300            .child(
301                v_flex()
302                    .flex_1()
303                    .overflow_hidden()
304                    .when(has_icon, |this| this.pl_6())
305                    .when_some(self.title.clone(), |this, title| {
306                        this.child(div().text_sm().font_semibold().child(title))
307                    })
308                    .when_some(self.message.clone(), |this, message| {
309                        this.child(div().text_sm().child(message))
310                    })
311                    .when_some(content, |this, content| {
312                        this.child(content)
313                    }),
314            )
315            .when_some(action, |this, action| {
316                this.child(action)
317            })
318            .when_some(self.on_click.clone(), |this, on_click| {
319                this.on_click(cx.listener(move |view, event, window, cx| {
320                    view.dismiss(window, cx);
321                    on_click(event, window, cx);
322                }))
323            })
324            .child(
325                h_flex()
326                    .absolute()
327                    .top_3p5()
328                    .right_3p5()
329                    .invisible()
330                    .group_hover("", |this| this.visible())
331                    .child(
332                        Button::new("close")
333                            .icon(IconName::Close)
334                            .ghost()
335                            .xsmall()
336                            .on_click(cx.listener(|this, _, window, cx| this.dismiss(window, cx))),
337                    ),
338            )
339            .with_animation(
340                ElementId::NamedInteger("slide-down".into(), closing as u64),
341                Animation::new(Duration::from_secs_f64(0.25))
342                    .with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
343                move |this, delta| {
344                    if closing {
345                        let x_offset = px(0.) + delta * px(45.);
346                        let opacity = 1. - delta;
347                        this.left(px(0.) + x_offset)
348                            .shadow_none()
349                            .opacity(opacity)
350                            .when(opacity < 0.85, |this| this.shadow_none())
351                    } else {
352                        let y_offset = px(-45.) + delta * px(45.);
353                        let opacity = delta;
354                        this.top(px(0.) + y_offset)
355                            .opacity(opacity)
356                            .when(opacity < 0.85, |this| this.shadow_none())
357                    }
358                },
359            )
360    }
361}
362
363/// A list of notifications.
364pub struct NotificationList {
365    /// Notifications that will be auto hidden.
366    pub(crate) notifications: VecDeque<Entity<Notification>>,
367    expanded: bool,
368    _subscriptions: HashMap<NotificationId, Subscription>,
369}
370
371impl NotificationList {
372    pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
373        Self {
374            notifications: VecDeque::new(),
375            expanded: false,
376            _subscriptions: HashMap::new(),
377        }
378    }
379
380    pub fn push(
381        &mut self,
382        notification: impl Into<Notification>,
383        window: &mut Window,
384        cx: &mut Context<Self>,
385    ) {
386        let notification = notification.into();
387        let id = notification.id.clone();
388        let autohide = notification.autohide;
389
390        // Remove the notification by id, for keep unique.
391        self.notifications.retain(|note| note.read(cx).id != id);
392
393        let notification = cx.new(|_| notification);
394
395        self._subscriptions.insert(
396            id.clone(),
397            cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| {
398                view.notifications.retain(|note| id != note.read(cx).id);
399                view._subscriptions.remove(&id);
400            }),
401        );
402
403        self.notifications.push_back(notification.clone());
404        if autohide {
405            // Sleep for 5 seconds to autohide the notification
406            cx.spawn_in(window, async move |_, cx| {
407                Timer::after(Duration::from_secs(5)).await;
408
409                if let Err(err) =
410                    notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
411                {
412                    tracing::error!("failed to auto hide notification: {:?}", err);
413                }
414            })
415            .detach();
416        }
417        cx.notify();
418    }
419
420    pub(crate) fn close(
421        &mut self,
422        id: impl Into<NotificationId>,
423        window: &mut Window,
424        cx: &mut Context<Self>,
425    ) {
426        let id: NotificationId = id.into();
427        if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
428            n.update(cx, |note, cx| note.dismiss(window, cx))
429        }
430        cx.notify();
431    }
432
433    pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
434        self.notifications.clear();
435        cx.notify();
436    }
437
438    pub fn notifications(&self) -> Vec<Entity<Notification>> {
439        self.notifications.iter().cloned().collect()
440    }
441}
442
443impl Render for NotificationList {
444    fn render(
445        &mut self,
446        window: &mut gpui::Window,
447        cx: &mut gpui::Context<Self>,
448    ) -> impl IntoElement {
449        let size = window.viewport_size();
450        let items = self.notifications.iter().rev().take(10).rev().cloned();
451
452        div().absolute().top_4().right_4().child(
453            v_flex()
454                .id("notification-list")
455                .h(size.height - px(8.))
456                .on_hover(cx.listener(|view, hovered, _, cx| {
457                    view.expanded = *hovered;
458                    cx.notify()
459                }))
460                .gap_3()
461                .children(items),
462        )
463    }
464}