Skip to main content

egui_charts/ui/widget_bar/panels/
notifications.rs

1//! The host's log of alert triggers and other events, newest first.
2
3use crate::localization as l10n;
4use std::collections::VecDeque;
5use std::sync::Arc;
6
7use egui::{RichText, Ui};
8
9use crate::data::inbox::Inbox;
10use crate::icons::icons;
11use crate::services::{Listener, Notification, NotificationTarget, Notifications, ProfileId};
12use crate::theme::ThemeExt;
13use crate::ui_kit::buttons::Button;
14use crate::ui_kit::{EmptyState, PanelHeader};
15
16/// Entries kept at most; older ones are dropped.
17const KEPT: usize = 1_000;
18
19/// An action the host performs for the notifications panel.
20#[derive(Clone, Debug, PartialEq, Default)]
21#[non_exhaustive]
22pub enum NotificationsAction {
23    /// No host action.
24    #[default]
25    None,
26    /// Show the symbol of an entry, such as on the chart.
27    SelectSymbol(String),
28    /// Show the profile an entry is about.
29    OpenProfile(ProfileId),
30    /// Show the chat room an entry is about.
31    OpenRoom(String),
32}
33
34/// The log pushed by the host's [`Notifications`] service.
35///
36/// Call [`poll`](Self::poll) each frame, even while the panel is hidden, and show
37/// [`unread`](Self::unread) as the bar entry's badge with
38/// [`WidgetBar::set_notification_cnt`](crate::ui::widget_bar::WidgetBar::set_notification_cnt).
39/// [`show`](Self::show) marks every entry read.
40#[derive(Default)]
41pub struct NotificationsPanel {
42    service: Option<Arc<dyn Notifications>>,
43    inbox: Arc<Inbox>,
44    listener: Option<Listener>,
45    generation: u64,
46    log: VecDeque<Notification>,
47    unread: usize,
48}
49
50impl NotificationsPanel {
51    /// An unconnected panel. Attach a service with [`set_service`](Self::set_service).
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Attach a host service, clearing the previous one's entries.
57    pub fn set_service(&mut self, service: Arc<dyn Notifications>) {
58        self.service = Some(service);
59        self.watch();
60    }
61
62    fn watch(&mut self) {
63        self.log.clear();
64        self.unread = 0;
65        self.inbox = Arc::default();
66        self.generation += 1;
67        let (listener, events) = Listener::new(&self.inbox, self.generation);
68        self.listener = Some(listener);
69        if let Some(service) = &self.service {
70            service.watch(events);
71        }
72    }
73
74    /// Entries pushed since the panel was last shown.
75    pub fn unread(&self) -> usize {
76        self.unread
77    }
78
79    /// Append a local entry, such as a terminal alert, to the same bounded log.
80    pub fn push(&mut self, entry: Notification) {
81        self.log.push_back(entry);
82        if self.log.len() > KEPT {
83            self.log.pop_front();
84        }
85        self.unread = (self.unread + 1).min(self.log.len());
86    }
87
88    /// Receive the service's pushes and return them, for popup notifications, and watch again
89    /// after a lagged stream. Entries added with [`push`](Self::push) are not returned.
90    pub fn poll(&mut self, ctx: &egui::Context) -> Vec<Notification> {
91        if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
92            self.watch();
93        }
94        let repaint = ctx.clone();
95        self.inbox.set_waker(move || repaint.request_repaint());
96        let mut received = Vec::new();
97        for (_, answer) in self.inbox.drain().answers {
98            if let Ok(entry) = answer.downcast::<Notification>() {
99                self.push((*entry).clone());
100                received.push(*entry);
101            }
102        }
103        received
104    }
105
106    /// Render the log, newest first, after [`poll`](Self::poll).
107    pub fn show(&mut self, ui: &mut Ui) -> NotificationsAction {
108        let mut action = NotificationsAction::None;
109        let theme = ui.active_theme();
110        self.unread = 0;
111        PanelHeader::new("Notifications").show_plain(ui);
112        if self.log.is_empty() {
113            EmptyState::new("No notifications yet")
114                .icon(&icons::WIDGET_BAR_NOTIFICATIONS)
115                .fill_height()
116                .show(ui);
117            return action;
118        }
119        egui::ScrollArea::vertical()
120            .auto_shrink([false, false])
121            .show(ui, |ui| {
122                for entry in self.log.iter().rev() {
123                    ui.scope_builder(egui::UiBuilder::new().sense(egui::Sense::hover()), |ui| {
124                        let hovered = ui.response().hovered();
125                        egui::Frame::new()
126                            .fill(if hovered {
127                                theme.ui.btn_bg_hover
128                            } else {
129                                egui::Color32::TRANSPARENT
130                            })
131                            .inner_margin(egui::Margin::symmetric(8, 0))
132                            .show(ui, |ui| {
133                                ui.set_width(ui.available_width());
134                                ui.set_min_height(26.0);
135                                ui.label(
136                                    RichText::new(l10n::text(
137                                        ui.ctx(),
138                                        entry.time.format("%b %-d, %H:%M:%S UTC").to_string(),
139                                    ))
140                                    .size(theme.text.small)
141                                    .color(theme.ui.text_secondary),
142                                );
143                                ui.label(
144                                    RichText::new(l10n::text(ui.ctx(), &entry.title)).strong(),
145                                );
146                                if !entry.body.is_empty() {
147                                    ui.label(l10n::text(ui.ctx(), &entry.body));
148                                }
149                                if let Some(symbol) = &entry.symbol
150                                    && Button::new(symbol).small().show(ui).clicked()
151                                {
152                                    action = NotificationsAction::SelectSymbol(symbol.clone());
153                                }
154                                if let Some(target) = &entry.target {
155                                    let (label, open) = match target {
156                                        NotificationTarget::Profile(id) => (
157                                            "Open profile",
158                                            NotificationsAction::OpenProfile(id.clone()),
159                                        ),
160                                        NotificationTarget::Room(room) => (
161                                            "Open room",
162                                            NotificationsAction::OpenRoom(room.clone()),
163                                        ),
164                                    };
165                                    if Button::new(l10n::text(ui.ctx(), label))
166                                        .small()
167                                        .show(ui)
168                                        .clicked()
169                                    {
170                                        action = open;
171                                    }
172                                }
173                            });
174                    });
175                    ui.separator();
176                }
177            });
178        action
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::services::Events;
186    use std::sync::Mutex;
187
188    #[derive(Default)]
189    struct Host(Mutex<Option<Events<Notification>>>);
190
191    impl Notifications for Host {
192        fn watch(&self, events: Events<Notification>) {
193            *self.0.lock().unwrap() = Some(events);
194        }
195    }
196
197    /// Guards the log against a lagged stream: the service pushes its recent entries again on
198    /// the new stream, so the panel must start the log over rather than show them twice, and the
199    /// badge must count only what is in the log.
200    #[test]
201    fn a_lagged_stream_starts_the_log_over() {
202        let host = Arc::new(Host::default());
203        let mut panel = NotificationsPanel::new();
204        panel.set_service(host.clone());
205        let ctx = egui::Context::default();
206        let entry =
207            |n: i64| Notification::new(chrono::DateTime::from_timestamp(n, 0).unwrap(), "x");
208        let events = host.0.lock().unwrap().clone().unwrap();
209        events.push(entry(1));
210        panel.poll(&ctx);
211        assert_eq!((panel.log.len(), panel.unread()), (1, 1));
212        for n in 0..=10_000 {
213            events.push(entry(n));
214        }
215        assert!(!events.is_active());
216        panel.poll(&ctx);
217        let renewed = host.0.lock().unwrap().clone().unwrap();
218        renewed.push(entry(1));
219        panel.poll(&ctx);
220        assert_eq!(panel.log, [entry(1)]);
221        assert_eq!(panel.unread(), 1);
222    }
223}