Skip to main content

gpui_kit/overlay/
notification_center.rs

1//! Where a notification goes after the toast that showed it has gone.
2//!
3//! A [`Toast`] is transient by design: it reports what happened and then gets
4//! out of the way. That is only acceptable if the report survives it, which is
5//! what this is for.
6//!
7//! The two are one record rather than two implementations. A host calls
8//! [`NotificationCenter::show`], which files the [`Notification`] here **and**
9//! pushes the toast built from that same record, so a toast that timed out has
10//! not been lost — it is in the centre, unread, with the same id, the same
11//! wording, and the same severity. Nothing about toasts is reimplemented here:
12//! timing, eviction, hover, and the rule that a failure never times out all
13//! stay in [`crate::overlay::toast`].
14//!
15//! The unread count is allowed to say it does not know. The centre holds a
16//! bounded number of records, and once a record has been dropped to make room
17//! the centre can no longer count what it no longer holds — so the count
18//! becomes [`UnreadCount::AtLeast`] and reads `9+` rather than a number that
19//! would be wrong.
20
21use std::rc::Rc;
22
23use gpui::{
24    AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
25    IntoElement, ParentElement, Render, SharedString, Styled, Window, div, px,
26};
27use gpui_kit_semantics::{NodeSpec, Role, Semantic};
28use gpui_kit_theme::{ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TypeScale};
29
30use crate::controls::button::{Button, IconButton};
31use crate::display::badge::{Badge, Tone};
32use crate::display::empty::{EmptyKind, EmptyState};
33use crate::display::status::StatusDot;
34use crate::foundation::{Ident, Sizable, StyledExt};
35use crate::overlay::toast::{self, Toast};
36use crate::strings::{ActiveStrings, StringKey};
37
38/// How many records the centre holds before the oldest is dropped.
39const DEFAULT_CAPACITY: usize = 50;
40
41type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
42
43/// One thing that happened, whether or not a toast ever showed it.
44///
45/// The severity is the library's existing [`Tone`] vocabulary rather than a
46/// second severity scale, so a notification reads the same in the centre as
47/// the toast of it read on screen.
48pub struct Notification {
49    ident: Ident,
50    message: SharedString,
51    detail: Option<SharedString>,
52    tone: Tone,
53    /// When it happened, as a string the host already put into words. The
54    /// reasoning is `Timeline`'s: this crate owns no clock and no locale.
55    at: Option<SharedString>,
56    read: bool,
57    action: Option<(SharedString, ActionHandler)>,
58}
59
60impl std::fmt::Debug for Notification {
61    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        formatter
63            .debug_struct("Notification")
64            .field("ident", &self.ident)
65            .field("message", &self.message)
66            .field("tone", &self.tone)
67            .field("read", &self.read)
68            .field("has_action", &self.action.is_some())
69            .finish()
70    }
71}
72
73impl Notification {
74    pub fn new(ident: impl Into<Ident>, message: impl Into<SharedString>) -> Self {
75        Self {
76            ident: ident.into(),
77            message: message.into(),
78            detail: None,
79            tone: Tone::default(),
80            at: None,
81            read: false,
82            action: None,
83        }
84    }
85
86    pub fn tone(mut self, tone: Tone) -> Self {
87        self.tone = tone;
88        self
89    }
90
91    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
92        self.detail = Some(detail.into());
93        self
94    }
95
96    /// When it happened, already worded by the host.
97    pub fn at(mut self, at: impl Into<SharedString>) -> Self {
98        self.at = Some(at.into());
99        self
100    }
101
102    /// Files it as already read, for a report the typist has plainly seen.
103    pub fn read(mut self, read: bool) -> Self {
104        self.read = read;
105        self
106    }
107
108    /// The one thing that can be done about it, as on a toast.
109    pub fn action(
110        mut self,
111        label: impl Into<SharedString>,
112        handler: impl Fn(&mut Window, &mut App) + 'static,
113    ) -> Self {
114        self.action = Some((label.into(), Rc::new(handler)));
115        self
116    }
117
118    pub fn id(&self) -> SharedString {
119        self.ident.semantic_id()
120    }
121
122    pub fn is_read(&self) -> bool {
123        self.read
124    }
125
126    pub fn message(&self) -> &SharedString {
127        &self.message
128    }
129
130    /// The transient showing of this same record.
131    ///
132    /// One record, two surfaces: the toast carries the same id, so dismissing
133    /// the toast and finding the notification afterwards are the same thing
134    /// seen twice rather than two things that happen to look alike.
135    pub fn toast(&self) -> Toast {
136        let mut toast = Toast::new(self.ident.clone(), self.message.clone()).tone(self.tone);
137        if let Some(detail) = self.detail.clone() {
138            toast = toast.detail(detail);
139        }
140        if let Some((label, handler)) = &self.action {
141            let handler = Rc::clone(handler);
142            toast = toast.action(label.clone(), move |window, cx| handler(window, cx));
143        }
144        toast
145    }
146}
147
148/// How many unread notifications there are, or how many there are at least.
149///
150/// A badge that shows a number it cannot stand behind is worse than one that
151/// says "more than this", so the second variant exists and is rendered
152/// differently.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum UnreadCount {
155    Exact(usize),
156    /// The centre has dropped records it no longer holds, so it can only speak
157    /// for the ones it still has.
158    AtLeast(usize),
159}
160
161impl UnreadCount {
162    pub fn value(self) -> usize {
163        match self {
164            Self::Exact(count) => count,
165            Self::AtLeast(count) => count,
166        }
167    }
168
169    pub fn is_zero(self) -> bool {
170        self.value() == 0
171    }
172
173    pub fn name(self) -> &'static str {
174        match self {
175            Self::Exact(_) => "exact",
176            Self::AtLeast(_) => "at-least",
177        }
178    }
179
180    fn wording(self, cx: &App) -> SharedString {
181        match self {
182            Self::Exact(count) => SharedString::from(count.to_string()),
183            Self::AtLeast(count) => cx
184                .strings()
185                .format(StringKey::NotificationsAtLeast, &[&count.to_string()]),
186        }
187    }
188}
189
190/// What the centre reports. It files records; it decides nothing else.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum NotificationCenterEvent {
193    /// One notification was marked read, by being opened or by the control.
194    Read(SharedString),
195    /// One notification was taken out of the centre.
196    Dismissed(SharedString),
197    /// Every notification was taken out.
198    Cleared,
199    /// A notification's own action was taken. The handler has already run.
200    ActionTaken(SharedString),
201}
202
203impl EventEmitter<NotificationCenterEvent> for NotificationCenter {}
204
205/// The list of notifications a window has accumulated.
206pub struct NotificationCenter {
207    ident: Ident,
208    focus_handle: FocusHandle,
209    notifications: Vec<Notification>,
210    capacity: usize,
211    /// Whether anything has been dropped to make room. Once this is true the
212    /// centre stops claiming an exact unread count, because it cannot know
213    /// whether what it dropped had been read.
214    dropped: bool,
215    size: ControlSize,
216}
217
218impl std::fmt::Debug for NotificationCenter {
219    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        formatter
221            .debug_struct("NotificationCenter")
222            .field("ident", &self.ident)
223            .field("notifications", &self.notifications.len())
224            .field("capacity", &self.capacity)
225            .field("dropped", &self.dropped)
226            .finish()
227    }
228}
229
230impl NotificationCenter {
231    pub fn new(ident: impl Into<Ident>, cx: &mut Context<Self>) -> Self {
232        Self {
233            ident: ident.into(),
234            focus_handle: cx.focus_handle(),
235            notifications: Vec::new(),
236            capacity: DEFAULT_CAPACITY,
237            dropped: false,
238            size: ControlSize::Sm,
239        }
240    }
241
242    /// How many records the centre holds before it drops the oldest. A
243    /// capacity below one is raised to one.
244    pub fn capacity(mut self, capacity: usize) -> Self {
245        self.capacity = capacity.max(1);
246        self
247    }
248
249    /// Files a notification and shows a toast of it, reporting whether the
250    /// toast was delivered.
251    ///
252    /// False means no [`ToastLayer`](crate::overlay::ToastLayer) is mounted,
253    /// so nothing floated on screen — but the record is filed either way,
254    /// which is exactly the point of the centre.
255    pub fn show(&mut self, notification: Notification, cx: &mut Context<Self>) -> bool {
256        let toast = notification.toast();
257        self.record(notification, cx);
258        toast::push(cx, toast)
259    }
260
261    /// Files a notification without showing a toast of it.
262    ///
263    /// A repeat of an id already held replaces it, the way a repeated toast
264    /// refreshes rather than stacking: one identity is one notification.
265    pub fn record(&mut self, notification: Notification, cx: &mut Context<Self>) {
266        let id = notification.id();
267        if let Some(existing) = self.notifications.iter_mut().find(|held| held.id() == id) {
268            *existing = notification;
269            cx.notify();
270            return;
271        }
272        self.notifications.push(notification);
273        while self.notifications.len() > self.capacity {
274            self.notifications.remove(0);
275            self.dropped = true;
276        }
277        cx.notify();
278    }
279
280    pub fn len(&self) -> usize {
281        self.notifications.len()
282    }
283
284    pub fn is_empty(&self) -> bool {
285        self.notifications.is_empty()
286    }
287
288    /// Whether the centre still holds this notification.
289    pub fn holds(&self, id: &str) -> bool {
290        self.notifications.iter().any(|held| held.id() == id)
291    }
292
293    /// Whether one notification has been read, or `None` when it is not here.
294    pub fn is_read(&self, id: &str) -> Option<bool> {
295        self.notifications
296            .iter()
297            .find(|held| held.id() == id)
298            .map(Notification::is_read)
299    }
300
301    pub fn unread(&self) -> UnreadCount {
302        let unread = self.notifications.iter().filter(|held| !held.read).count();
303        if self.dropped {
304            UnreadCount::AtLeast(unread)
305        } else {
306            UnreadCount::Exact(unread)
307        }
308    }
309
310    pub fn mark_read(&mut self, id: &str, cx: &mut Context<Self>) -> bool {
311        let Some(held) = self.notifications.iter_mut().find(|held| held.id() == id) else {
312            return false;
313        };
314        if held.read {
315            return false;
316        }
317        held.read = true;
318        cx.emit(NotificationCenterEvent::Read(SharedString::from(
319            id.to_string(),
320        )));
321        cx.notify();
322        true
323    }
324
325    pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
326        let ids: Vec<SharedString> = self
327            .notifications
328            .iter_mut()
329            .filter(|held| !held.read)
330            .map(|held| {
331                held.read = true;
332                held.id()
333            })
334            .collect();
335        for id in ids {
336            cx.emit(NotificationCenterEvent::Read(id));
337        }
338        cx.notify();
339    }
340
341    /// Takes one notification out, reporting whether it was here.
342    pub fn dismiss(&mut self, id: &str, cx: &mut Context<Self>) -> bool {
343        let Some(index) = self.notifications.iter().position(|held| held.id() == id) else {
344            return false;
345        };
346        self.notifications.remove(index);
347        cx.emit(NotificationCenterEvent::Dismissed(SharedString::from(
348            id.to_string(),
349        )));
350        cx.notify();
351        true
352    }
353
354    /// Takes every notification out.
355    ///
356    /// Dismissing one and clearing them all are separate reports, because
357    /// clearing is the gesture that loses reports nobody has read and a host
358    /// may well want to ask about it.
359    pub fn clear(&mut self, cx: &mut Context<Self>) {
360        self.notifications.clear();
361        // Nothing is held, so nothing was dropped from view either: an empty
362        // centre counts zero exactly.
363        self.dropped = false;
364        cx.emit(NotificationCenterEvent::Cleared);
365        cx.notify();
366    }
367
368    fn row(&self, index: usize, cx: &mut Context<Self>) -> AnyElement {
369        let theme = cx.theme().clone();
370        let notification = &self.notifications[index];
371        let id = notification.id();
372        let ident = notification.ident.clone();
373
374        let action = notification.action.as_ref().map(|(label, handler)| {
375            let handler = Rc::clone(handler);
376            let centre = cx.entity().downgrade();
377            let reported = id.clone();
378            Button::new(ident.child("action"))
379                .semantic_parent(id.clone())
380                .label(label.clone())
381                .ghost()
382                .control_size(ControlSize::Xs)
383                .on_click(move |window, cx| {
384                    handler(window, cx);
385                    let reported = reported.clone();
386                    centre
387                        .update(cx, |centre, cx| {
388                            centre.mark_read(reported.as_ref(), cx);
389                            cx.emit(NotificationCenterEvent::ActionTaken(reported));
390                        })
391                        .ok();
392                })
393        });
394
395        let dismiss = {
396            let centre = cx.entity().downgrade();
397            let dismissed = id.clone();
398            IconButton::new(
399                ident.child("dismiss"),
400                gpui_kit_assets::Icon::Close,
401                cx.strings().text(StringKey::Dismiss),
402            )
403            .semantic_parent(id.clone())
404            .control_size(ControlSize::Xs)
405            .on_click(move |_, cx| {
406                let dismissed = dismissed.clone();
407                centre
408                    .update(cx, |centre, cx| centre.dismiss(dismissed.as_ref(), cx))
409                    .ok();
410            })
411        };
412
413        let unread_mark = (!notification.read).then(|| {
414            div()
415                .flex_none()
416                .size(px(6.0))
417                .rounded_full()
418                .bg(theme.colors.accent)
419                .semantic_in(
420                    cx,
421                    NodeSpec::new(ident.child("unread").semantic_id(), Role::Status)
422                        .parent(id.clone())
423                        .text(cx.strings().text(StringKey::NotificationsUnread)),
424                )
425        });
426
427        div()
428            .row()
429            .w_full()
430            .items_start()
431            .gap_token(&theme, Space::Sm)
432            .px_token(&theme, Space::Sm)
433            .py_token(&theme, Space::Xs)
434            .child(
435                div()
436                    .flex_none()
437                    .mt(px(5.0))
438                    .child(StatusDot::new(notification.tone)),
439            )
440            .child(
441                div()
442                    .column()
443                    .flex_1()
444                    .min_w_0()
445                    .gap_token(&theme, Space::Xs)
446                    .child(
447                        div()
448                            .type_scale(&theme, TypeScale::Label)
449                            // A record that has been read steps back rather
450                            // than disappearing: it is still a report.
451                            .text_color(if notification.read {
452                                theme.colors.text_muted
453                            } else {
454                                theme.colors.text
455                            })
456                            .child(notification.message.clone()),
457                    )
458                    .children(notification.detail.clone().map(|detail| {
459                        div()
460                            .type_scale(&theme, TypeScale::Caption)
461                            .text_color(theme.colors.text_muted)
462                            .child(detail)
463                    }))
464                    .children(notification.at.clone().map(|at| {
465                        div()
466                            .type_scale(&theme, TypeScale::Caption)
467                            .text_color(theme.colors.text_faint)
468                            .child(at)
469                    })),
470            )
471            .children(unread_mark)
472            .children(action)
473            .child(dismiss)
474            .semantic_in(
475                cx,
476                NodeSpec::new(id, Role::Row)
477                    .parent(self.ident.semantic_id())
478                    .text(notification.message.clone())
479                    // The severity is published by name, so a test reads the
480                    // claim rather than the colour.
481                    .value(notification.tone.name())
482                    .checked(notification.read),
483            )
484            .into_any_element()
485    }
486}
487
488impl Focusable for NotificationCenter {
489    fn focus_handle(&self, _cx: &App) -> FocusHandle {
490        self.focus_handle.clone()
491    }
492}
493
494impl Sizable for NotificationCenter {
495    fn control_size(mut self, size: ControlSize) -> Self {
496        self.size = size;
497        self
498    }
499}
500
501impl Render for NotificationCenter {
502    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
503        let theme = cx.theme().clone();
504        let unread = self.unread();
505        let count_ident = self.ident.child("unread");
506
507        let badge = (!unread.is_zero()).then(|| {
508            Badge::new(unread.wording(cx))
509                .tone(Tone::Accent)
510                .id(count_ident.clone())
511        });
512
513        let mark_all = (!unread.is_zero()).then(|| {
514            let centre = cx.entity().downgrade();
515            Button::new(self.ident.child("mark-all-read"))
516                .label(cx.strings().text(StringKey::NotificationsMarkAllRead))
517                .ghost()
518                .control_size(ControlSize::Xs)
519                .semantic_parent(self.ident.semantic_id())
520                .on_click(move |_, cx| {
521                    centre
522                        .update(cx, |centre, cx| centre.mark_all_read(cx))
523                        .ok();
524                })
525        });
526
527        let clear_all = (!self.notifications.is_empty()).then(|| {
528            let centre = cx.entity().downgrade();
529            Button::new(self.ident.child("clear-all"))
530                .label(cx.strings().text(StringKey::NotificationsClearAll))
531                .ghost()
532                .control_size(ControlSize::Xs)
533                .semantic_parent(self.ident.semantic_id())
534                .on_click(move |_, cx| {
535                    centre.update(cx, |centre, cx| centre.clear(cx)).ok();
536                })
537        });
538
539        let rows: Vec<AnyElement> = (0..self.notifications.len())
540            // Newest first: the thing that just happened is the thing being
541            // looked for.
542            .rev()
543            .map(|index| self.row(index, cx))
544            .collect();
545
546        let body = if rows.is_empty() {
547            EmptyState::new(
548                self.ident.child("empty"),
549                cx.strings().text(StringKey::NotificationsEmpty),
550            )
551            .kind(EmptyKind::Empty)
552            .detail(cx.strings().text(StringKey::NotificationsEmptyDetail))
553            .into_any_element()
554        } else {
555            div().column().w_full().children(rows).into_any_element()
556        };
557
558        div()
559            .id(self.ident.element_id())
560            .column()
561            .w_full()
562            .radius(&theme, Radius::Card)
563            .frame(&theme, Surface::Panel, Elevation::Raised)
564            .child(
565                div()
566                    .row()
567                    .w_full()
568                    .gap_token(&theme, Space::Sm)
569                    .px_token(&theme, Space::Sm)
570                    .py_token(&theme, Space::Xs)
571                    .surface(&theme, Surface::Raised)
572                    .child(
573                        div()
574                            .type_scale(&theme, TypeScale::Label)
575                            .child(cx.strings().text(StringKey::NotificationsTitle)),
576                    )
577                    .children(badge)
578                    .child(div().flex_1())
579                    .children(mark_all)
580                    .children(clear_all),
581            )
582            .child(body)
583            .semantic_in(
584                cx,
585                NodeSpec::new(self.ident.semantic_id(), Role::List)
586                    // The value is the badge's own claim, so a test reads what
587                    // the badge says rather than counting rows.
588                    .text(cx.strings().format(
589                        StringKey::NotificationsUnreadCount,
590                        &[unread.wording(cx).as_ref()],
591                    ))
592                    .value(unread.wording(cx)),
593            )
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn a_count_that_lost_records_stops_claiming_to_be_exact() {
603        assert_eq!(UnreadCount::Exact(3).value(), 3);
604        assert_eq!(UnreadCount::AtLeast(3).value(), 3);
605        assert_ne!(UnreadCount::Exact(3), UnreadCount::AtLeast(3));
606        assert_eq!(UnreadCount::Exact(0).name(), "exact");
607        assert_eq!(UnreadCount::AtLeast(0).name(), "at-least");
608    }
609
610    #[test]
611    fn a_notification_and_its_toast_share_one_identity() {
612        let notification = Notification::new("run.failed", "Refused").tone(Tone::Danger);
613        assert_eq!(
614            notification.toast().ident().semantic_id(),
615            notification.id()
616        );
617    }
618}