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 Window, &mut Context<Self>) -> Button>>,
74    content_builder: Option<Rc<dyn Fn(&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    pub fn new() -> Self {
114        let id: SharedString = uuid::Uuid::new_v4().to_string().into();
115        let id = (TypeId::of::<DefaultIdType>(), id.into());
116
117        Self {
118            id: id.into(),
119            style: StyleRefinement::default(),
120            title: None,
121            message: None,
122            type_: None,
123            icon: None,
124            autohide: true,
125            action_builder: None,
126            content_builder: None,
127            on_click: None,
128            closing: false,
129        }
130    }
131
132    /// Set the message of the notification, default is None.
133    pub fn message(mut self, message: impl Into<SharedString>) -> Self {
134        self.message = Some(message.into());
135        self
136    }
137
138    /// Create an info notification with the given message.
139    pub fn info(message: impl Into<SharedString>) -> Self {
140        Self::new()
141            .message(message)
142            .with_type(NotificationType::Info)
143    }
144
145    /// Create a success notification with the given message.
146    pub fn success(message: impl Into<SharedString>) -> Self {
147        Self::new()
148            .message(message)
149            .with_type(NotificationType::Success)
150    }
151
152    /// Create a warning notification with the given message.
153    pub fn warning(message: impl Into<SharedString>) -> Self {
154        Self::new()
155            .message(message)
156            .with_type(NotificationType::Warning)
157    }
158
159    /// Create an error notification with the given message.
160    pub fn error(message: impl Into<SharedString>) -> Self {
161        Self::new()
162            .message(message)
163            .with_type(NotificationType::Error)
164    }
165
166    /// Set the type for unique identification of the notification.
167    ///
168    /// ```rs
169    /// struct MyNotificationKind;
170    /// let notification = Notification::new("Hello").id::<MyNotificationKind>();
171    /// ```
172    pub fn id<T: Sized + 'static>(mut self) -> Self {
173        self.id = TypeId::of::<T>().into();
174        self
175    }
176
177    /// Set the type and id of the notification, used to uniquely identify the notification.
178    pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
179        self.id = (TypeId::of::<T>(), key.into()).into();
180        self
181    }
182
183    /// Set the title of the notification, default is None.
184    ///
185    /// If title is None, the notification will not have a title.
186    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
187        self.title = Some(title.into());
188        self
189    }
190
191    /// Set the icon of the notification.
192    ///
193    /// If icon is None, the notification will use the default icon of the type.
194    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
195        self.icon = Some(icon.into());
196        self
197    }
198
199    /// Set the type of the notification, default is NotificationType::Info.
200    pub fn with_type(mut self, type_: NotificationType) -> Self {
201        self.type_ = Some(type_);
202        self
203    }
204
205    /// Set the auto hide of the notification, default is true.
206    pub fn autohide(mut self, autohide: bool) -> Self {
207        self.autohide = autohide;
208        self
209    }
210
211    /// Set the click callback of the notification.
212    pub fn on_click(
213        mut self,
214        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
215    ) -> Self {
216        self.on_click = Some(Rc::new(on_click));
217        self
218    }
219
220    /// Set the action button of the notification.
221    pub fn action<F>(mut self, action: F) -> Self
222    where
223        F: Fn(&mut Window, &mut Context<Self>) -> Button + 'static,
224    {
225        self.action_builder = Some(Rc::new(action));
226        self
227    }
228
229    /// Dismiss the notification.
230    pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
231        self.closing = true;
232        cx.notify();
233
234        // Dismiss the notification after 0.15s to show the animation.
235        cx.spawn(async move |view, cx| {
236            Timer::after(Duration::from_secs_f32(0.15)).await;
237            cx.update(|cx| {
238                if let Some(view) = view.upgrade() {
239                    view.update(cx, |view, cx| {
240                        view.closing = false;
241                        cx.emit(DismissEvent);
242                    });
243                }
244            })
245        })
246        .detach()
247    }
248
249    /// Set the content of the notification.
250    pub fn content(
251        mut self,
252        content: impl Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
253    ) -> Self {
254        self.content_builder = Some(Rc::new(content));
255        self
256    }
257}
258impl EventEmitter<DismissEvent> for Notification {}
259impl FluentBuilder for Notification {}
260impl Styled for Notification {
261    fn style(&mut self) -> &mut StyleRefinement {
262        &mut self.style
263    }
264}
265impl Render for Notification {
266    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
267        let closing = self.closing;
268        let icon = match self.type_ {
269            None => self.icon.clone(),
270            Some(type_) => Some(type_.icon(cx)),
271        };
272        let has_icon = icon.is_some();
273
274        h_flex()
275            .id("notification")
276            .group("")
277            .occlude()
278            .relative()
279            .w_112()
280            .border_1()
281            .border_color(cx.theme().border)
282            .bg(cx.theme().popover)
283            .rounded(cx.theme().radius_lg)
284            .shadow_md()
285            .py_3p5()
286            .px_4()
287            .gap_3()
288            .refine_style(&self.style)
289            .when_some(icon, |this, icon| {
290                this.child(div().absolute().py_3p5().left_4().child(icon))
291            })
292            .child(
293                v_flex()
294                    .flex_1()
295                    .overflow_hidden()
296                    .when(has_icon, |this| this.pl_6())
297                    .when_some(self.title.clone(), |this, title| {
298                        this.child(div().text_sm().font_semibold().child(title))
299                    })
300                    .when_some(self.message.clone(), |this, message| {
301                        this.child(div().text_sm().child(message))
302                    })
303                    .when_some(self.content_builder.clone(), |this, child_builder| {
304                        this.child(child_builder(window, cx))
305                    }),
306            )
307            .when_some(self.action_builder.clone(), |this, action_builder| {
308                this.child(action_builder(window, cx).small().mr_3p5())
309            })
310            .when_some(self.on_click.clone(), |this, on_click| {
311                this.on_click(cx.listener(move |view, event, window, cx| {
312                    view.dismiss(window, cx);
313                    on_click(event, window, cx);
314                }))
315            })
316            .child(
317                h_flex()
318                    .absolute()
319                    .top_3p5()
320                    .right_3p5()
321                    .invisible()
322                    .group_hover("", |this| this.visible())
323                    .child(
324                        Button::new("close")
325                            .icon(IconName::Close)
326                            .ghost()
327                            .xsmall()
328                            .on_click(cx.listener(|this, _, window, cx| this.dismiss(window, cx))),
329                    ),
330            )
331            .with_animation(
332                ElementId::NamedInteger("slide-down".into(), closing as u64),
333                Animation::new(Duration::from_secs_f64(0.25))
334                    .with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
335                move |this, delta| {
336                    if closing {
337                        let x_offset = px(0.) + delta * px(45.);
338                        let opacity = 1. - delta;
339                        this.left(px(0.) + x_offset)
340                            .shadow_none()
341                            .opacity(opacity)
342                            .when(opacity < 0.85, |this| this.shadow_none())
343                    } else {
344                        let y_offset = px(-45.) + delta * px(45.);
345                        let opacity = delta;
346                        this.top(px(0.) + y_offset)
347                            .opacity(opacity)
348                            .when(opacity < 0.85, |this| this.shadow_none())
349                    }
350                },
351            )
352    }
353}
354
355/// A list of notifications.
356pub struct NotificationList {
357    /// Notifications that will be auto hidden.
358    pub(crate) notifications: VecDeque<Entity<Notification>>,
359    expanded: bool,
360    _subscriptions: HashMap<NotificationId, Subscription>,
361}
362
363impl NotificationList {
364    pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
365        Self {
366            notifications: VecDeque::new(),
367            expanded: false,
368            _subscriptions: HashMap::new(),
369        }
370    }
371
372    pub fn push(
373        &mut self,
374        notification: impl Into<Notification>,
375        window: &mut Window,
376        cx: &mut Context<Self>,
377    ) {
378        let notification = notification.into();
379        let id = notification.id.clone();
380        let autohide = notification.autohide;
381
382        // Remove the notification by id, for keep unique.
383        self.notifications.retain(|note| note.read(cx).id != id);
384
385        let notification = cx.new(|_| notification);
386
387        self._subscriptions.insert(
388            id.clone(),
389            cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| {
390                view.notifications.retain(|note| id != note.read(cx).id);
391                view._subscriptions.remove(&id);
392            }),
393        );
394
395        self.notifications.push_back(notification.clone());
396        if autohide {
397            // Sleep for 5 seconds to autohide the notification
398            cx.spawn_in(window, async move |_, cx| {
399                Timer::after(Duration::from_secs(5)).await;
400
401                if let Err(err) =
402                    notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
403                {
404                    tracing::error!("failed to auto hide notification: {:?}", err);
405                }
406            })
407            .detach();
408        }
409        cx.notify();
410    }
411
412    pub(crate) fn close(
413        &mut self,
414        id: impl Into<NotificationId>,
415        window: &mut Window,
416        cx: &mut Context<Self>,
417    ) {
418        let id: NotificationId = id.into();
419        if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
420            n.update(cx, |note, cx| note.dismiss(window, cx))
421        }
422        cx.notify();
423    }
424
425    pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
426        self.notifications.clear();
427        cx.notify();
428    }
429
430    pub fn notifications(&self) -> Vec<Entity<Notification>> {
431        self.notifications.iter().cloned().collect()
432    }
433}
434
435impl Render for NotificationList {
436    fn render(
437        &mut self,
438        window: &mut gpui::Window,
439        cx: &mut gpui::Context<Self>,
440    ) -> impl IntoElement {
441        let size = window.viewport_size();
442        let items = self.notifications.iter().rev().take(10).rev().cloned();
443
444        div().absolute().top_4().right_4().child(
445            v_flex()
446                .id("notification-list")
447                .h(size.height - px(8.))
448                .on_hover(cx.listener(|view, hovered, _, cx| {
449                    view.expanded = *hovered;
450                    cx.notify()
451                }))
452                .gap_3()
453                .children(items),
454        )
455    }
456}