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        self.closing = true;
234        cx.notify();
235
236        // Dismiss the notification after 0.15s to show the animation.
237        cx.spawn(async move |view, cx| {
238            Timer::after(Duration::from_secs_f32(0.15)).await;
239            cx.update(|cx| {
240                if let Some(view) = view.upgrade() {
241                    view.update(cx, |view, cx| {
242                        view.closing = false;
243                        cx.emit(DismissEvent);
244                    });
245                }
246            })
247        })
248        .detach()
249    }
250
251    /// Set the content of the notification.
252    pub fn content(
253        mut self,
254        content: impl Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement + 'static,
255    ) -> Self {
256        self.content_builder = Some(Rc::new(content));
257        self
258    }
259}
260impl EventEmitter<DismissEvent> for Notification {}
261impl FluentBuilder for Notification {}
262impl Styled for Notification {
263    fn style(&mut self) -> &mut StyleRefinement {
264        &mut self.style
265    }
266}
267impl Render for Notification {
268    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
269        let closing = self.closing;
270        let icon = match self.type_ {
271            None => self.icon.clone(),
272            Some(type_) => Some(type_.icon(cx)),
273        };
274        let has_icon = icon.is_some();
275
276        h_flex()
277            .id("notification")
278            .group("")
279            .occlude()
280            .relative()
281            .w_112()
282            .border_1()
283            .border_color(cx.theme().border)
284            .bg(cx.theme().popover)
285            .rounded(cx.theme().radius_lg)
286            .shadow_md()
287            .py_3p5()
288            .px_4()
289            .gap_3()
290            .refine_style(&self.style)
291            .when_some(icon, |this, icon| {
292                this.child(div().absolute().py_3p5().left_4().child(icon))
293            })
294            .child(
295                v_flex()
296                    .flex_1()
297                    .overflow_hidden()
298                    .when(has_icon, |this| this.pl_6())
299                    .when_some(self.title.clone(), |this, title| {
300                        this.child(div().text_sm().font_semibold().child(title))
301                    })
302                    .when_some(self.message.clone(), |this, message| {
303                        this.child(div().text_sm().child(message))
304                    })
305                    .when_some(self.content_builder.clone(), |this, child_builder| {
306                        this.child(child_builder(self, window, cx))
307                    }),
308            )
309            .when_some(self.action_builder.clone(), |this, action_builder| {
310                this.child(action_builder(self, window, cx).small().mr_3p5())
311            })
312            .when_some(self.on_click.clone(), |this, on_click| {
313                this.on_click(cx.listener(move |view, event, window, cx| {
314                    view.dismiss(window, cx);
315                    on_click(event, window, cx);
316                }))
317            })
318            .child(
319                h_flex()
320                    .absolute()
321                    .top_3p5()
322                    .right_3p5()
323                    .invisible()
324                    .group_hover("", |this| this.visible())
325                    .child(
326                        Button::new("close")
327                            .icon(IconName::Close)
328                            .ghost()
329                            .xsmall()
330                            .on_click(cx.listener(|this, _, window, cx| this.dismiss(window, cx))),
331                    ),
332            )
333            .with_animation(
334                ElementId::NamedInteger("slide-down".into(), closing as u64),
335                Animation::new(Duration::from_secs_f64(0.25))
336                    .with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
337                move |this, delta| {
338                    if closing {
339                        let x_offset = px(0.) + delta * px(45.);
340                        let opacity = 1. - delta;
341                        this.left(px(0.) + x_offset)
342                            .shadow_none()
343                            .opacity(opacity)
344                            .when(opacity < 0.85, |this| this.shadow_none())
345                    } else {
346                        let y_offset = px(-45.) + delta * px(45.);
347                        let opacity = delta;
348                        this.top(px(0.) + y_offset)
349                            .opacity(opacity)
350                            .when(opacity < 0.85, |this| this.shadow_none())
351                    }
352                },
353            )
354    }
355}
356
357/// A list of notifications.
358pub struct NotificationList {
359    /// Notifications that will be auto hidden.
360    pub(crate) notifications: VecDeque<Entity<Notification>>,
361    expanded: bool,
362    _subscriptions: HashMap<NotificationId, Subscription>,
363}
364
365impl NotificationList {
366    pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
367        Self {
368            notifications: VecDeque::new(),
369            expanded: false,
370            _subscriptions: HashMap::new(),
371        }
372    }
373
374    pub fn push(
375        &mut self,
376        notification: impl Into<Notification>,
377        window: &mut Window,
378        cx: &mut Context<Self>,
379    ) {
380        let notification = notification.into();
381        let id = notification.id.clone();
382        let autohide = notification.autohide;
383
384        // Remove the notification by id, for keep unique.
385        self.notifications.retain(|note| note.read(cx).id != id);
386
387        let notification = cx.new(|_| notification);
388
389        self._subscriptions.insert(
390            id.clone(),
391            cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| {
392                view.notifications.retain(|note| id != note.read(cx).id);
393                view._subscriptions.remove(&id);
394            }),
395        );
396
397        self.notifications.push_back(notification.clone());
398        if autohide {
399            // Sleep for 5 seconds to autohide the notification
400            cx.spawn_in(window, async move |_, cx| {
401                Timer::after(Duration::from_secs(5)).await;
402
403                if let Err(err) =
404                    notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
405                {
406                    tracing::error!("failed to auto hide notification: {:?}", err);
407                }
408            })
409            .detach();
410        }
411        cx.notify();
412    }
413
414    pub(crate) fn close(
415        &mut self,
416        id: impl Into<NotificationId>,
417        window: &mut Window,
418        cx: &mut Context<Self>,
419    ) {
420        let id: NotificationId = id.into();
421        if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
422            n.update(cx, |note, cx| note.dismiss(window, cx))
423        }
424        cx.notify();
425    }
426
427    pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
428        self.notifications.clear();
429        cx.notify();
430    }
431
432    pub fn notifications(&self) -> Vec<Entity<Notification>> {
433        self.notifications.iter().cloned().collect()
434    }
435}
436
437impl Render for NotificationList {
438    fn render(
439        &mut self,
440        window: &mut gpui::Window,
441        cx: &mut gpui::Context<Self>,
442    ) -> impl IntoElement {
443        let size = window.viewport_size();
444        let items = self.notifications.iter().rev().take(10).rev().cloned();
445
446        div().absolute().top_4().right_4().child(
447            v_flex()
448                .id("notification-list")
449                .h(size.height - px(8.))
450                .on_hover(cx.listener(|view, hovered, _, cx| {
451                    view.expanded = *hovered;
452                    cx.notify()
453                }))
454                .gap_3()
455                .children(items),
456        )
457    }
458}