1use 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
38const DEFAULT_CAPACITY: usize = 50;
40
41type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
42
43pub struct Notification {
49 ident: Ident,
50 message: SharedString,
51 detail: Option<SharedString>,
52 tone: Tone,
53 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 pub fn at(mut self, at: impl Into<SharedString>) -> Self {
98 self.at = Some(at.into());
99 self
100 }
101
102 pub fn read(mut self, read: bool) -> Self {
104 self.read = read;
105 self
106 }
107
108 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum UnreadCount {
155 Exact(usize),
156 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#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum NotificationCenterEvent {
193 Read(SharedString),
195 Dismissed(SharedString),
197 Cleared,
199 ActionTaken(SharedString),
201}
202
203impl EventEmitter<NotificationCenterEvent> for NotificationCenter {}
204
205pub struct NotificationCenter {
207 ident: Ident,
208 focus_handle: FocusHandle,
209 notifications: Vec<Notification>,
210 capacity: usize,
211 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 pub fn capacity(mut self, capacity: usize) -> Self {
245 self.capacity = capacity.max(1);
246 self
247 }
248
249 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 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 pub fn holds(&self, id: &str) -> bool {
290 self.notifications.iter().any(|held| held.id() == id)
291 }
292
293 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 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 pub fn clear(&mut self, cx: &mut Context<Self>) {
360 self.notifications.clear();
361 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 .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 .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 .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 .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}