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