Skip to main content

egui_charts/ui/widget_bar/panels/
alerts_panel.rs

1//! Host-supplied alerts and their trigger log. The host stores and evaluates alerts.
2
3use crate::localization as l10n;
4use std::collections::VecDeque;
5use std::sync::Arc;
6
7use chrono::{DateTime, Utc};
8use egui::{Color32, RichText, Ui};
9
10use crate::data::{FeedError, inbox::Inbox};
11use crate::icons::icons;
12use crate::model::{Alert, AlertCondition, AlertId, AlertSource, AlertStatus};
13use crate::services::{AlertEvent, Alerts, Listener};
14use crate::theme::ThemeExt;
15use crate::ui::dialogs::alert_dialog::rule_form;
16use crate::ui_kit::buttons::Button;
17use crate::ui_kit::notifications::{Toast, Toasts};
18use crate::ui_kit::{EmptyState, IconButton, PanelHeader};
19
20/// Configuration for the alerts panel.
21#[derive(Clone, Debug)]
22pub struct AlertsPanelConfig {
23    /// Maximum height of the scrolling list.
24    pub max_height: f32,
25}
26
27impl Default for AlertsPanelConfig {
28    fn default() -> Self {
29        Self { max_height: 400.0 }
30    }
31}
32
33/// An action the host handles using its shared alert dialog or file export.
34#[derive(Clone, Debug, PartialEq, Default)]
35#[non_exhaustive]
36pub enum AlertPanelAction {
37    /// No host action.
38    #[default]
39    None,
40    /// Open the shared alert creation dialog; submit its result with [`AlertsPanel::save`].
41    CreateAlert,
42    /// Edit this host-owned alert in the shared dialog.
43    EditAlert(Box<Alert>),
44    /// Stage this copy of an alert, without an identifier and active, in the shared dialog
45    /// with [`AlertDialog::edit`](crate::ui::dialogs::alert_dialog::AlertDialog::edit).
46    DuplicateAlert(Box<Alert>),
47    /// Save this CSV text to a file chosen by the host.
48    ExportLog(String),
49}
50
51/// The property the alert list is ordered by.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53enum AlertSortKey {
54    /// Arrival order, as the host pushed the alerts.
55    #[default]
56    Arrival,
57    /// The alert name, ignoring case.
58    Name,
59    /// The symbol, ignoring case.
60    Symbol,
61    /// Active, triggered, disabled, then expired.
62    Status,
63}
64
65/// One trigger the host reported, retained even after the alert is removed.
66#[derive(Clone, Debug, PartialEq)]
67pub struct AlertTrigger {
68    /// Host alert identifier.
69    pub id: AlertId,
70    /// Symbol as known when the trigger arrived, empty if its alert was not yet supplied.
71    pub symbol: String,
72    /// Host trigger time, including triggers missed while the application was closed.
73    pub time: DateTime<Utc>,
74    /// Finite trigger price, if supplied.
75    pub price: Option<f64>,
76    /// The alert's message with its placeholders filled in, empty if there is none.
77    pub message: String,
78}
79
80/// Reply key of the inline draft's save, apart from the stream generations and other replies.
81const DRAFT_REPLY: u64 = u64::MAX;
82
83/// An alert panel connected to the host's [`Alerts`] service.
84///
85/// Call [`poll`](Self::poll) each frame even while hidden, so triggers produce notifications.
86/// Creation returns an intent to open the shared alert dialog. Only host pushes change the list;
87/// successful saves and removals never invent local state. Accounts, evaluation and delivery
88/// outside the application remain the host's responsibility.
89pub struct AlertsPanel {
90    config: AlertsPanelConfig,
91    service: Option<Arc<dyn Alerts>>,
92    inbox: Arc<Inbox>,
93    listener: Option<Listener>,
94    generation: u64,
95    alerts: Vec<Alert>,
96    log: VecDeque<AlertTrigger>,
97    error: Option<FeedError>,
98    show_log: bool,
99    notification_seconds: f32,
100    sort: AlertSortKey,
101    descending: bool,
102    sources: Vec<(AlertSource, String)>,
103    symbol: String,
104    draft: Alert,
105    inputs: Option<(crate::model::AlertInputs, Option<crate::model::Timeframe>)>,
106    draft_saving: bool,
107    pending: usize,
108    sound_choice: Option<String>,
109    sound: Option<String>,
110}
111
112impl Default for AlertsPanel {
113    fn default() -> Self {
114        Self::new(AlertsPanelConfig::default())
115    }
116}
117
118impl AlertsPanel {
119    /// Create an unconnected panel. Attach a service with [`set_service`](Self::set_service).
120    pub fn new(config: AlertsPanelConfig) -> Self {
121        Self {
122            config,
123            service: None,
124            inbox: Arc::default(),
125            listener: None,
126            generation: 0,
127            alerts: Vec::new(),
128            log: VecDeque::new(),
129            error: None,
130            show_log: false,
131            notification_seconds: 5.0,
132            sort: AlertSortKey::default(),
133            descending: false,
134            sources: Vec::new(),
135            symbol: String::new(),
136            draft: Alert::new("", AlertCondition::Crossing(0.0)),
137            inputs: None,
138            draft_saving: false,
139            pending: 0,
140            sound_choice: None,
141            sound: None,
142        }
143    }
144
145    /// Start the Quick alert form on `symbol`, such as the chart's. Call it every frame: the form
146    /// follows a change of `symbol` and keeps what the user typed otherwise.
147    pub fn set_symbol(&mut self, symbol: &str) {
148        if self.symbol != symbol {
149            self.symbol = symbol.to_owned();
150            self.draft.symbol = symbol.to_owned();
151        }
152    }
153
154    /// Offer these study outputs and drawings, as stable identifiers and display names, to the
155    /// inline alert form.
156    pub fn set_sources(&mut self, sources: Vec<(AlertSource, String)>) {
157        self.sources = sources;
158    }
159
160    /// Creation inputs for the inline form, captured from its source view by the host.
161    pub fn set_inputs(
162        &mut self,
163        inputs: crate::model::AlertInputs,
164        timeframe: Option<crate::model::Timeframe>,
165    ) {
166        self.inputs = Some((inputs, timeframe));
167    }
168
169    /// Keep each trigger notification on screen for this many seconds, five by default.
170    pub fn set_notification_seconds(&mut self, seconds: f32) {
171        self.notification_seconds = if seconds.is_finite() {
172            seconds.max(0.0)
173        } else {
174            5.0
175        };
176    }
177
178    /// The sound to play when an alert fires, as the user chose it in the alert settings: its
179    /// name, empty for the host's first sound, or `None` when the user turned the sound off.
180    pub fn set_sound(&mut self, sound: Option<String>) {
181        self.sound_choice = sound;
182    }
183
184    /// The sound to play for the triggers the last [`poll`](Self::poll) received, if it received
185    /// one and [`set_sound`](Self::set_sound) holds a sound. The host plays it, as with
186    /// `Trading::take_execution_sound`; call this after `poll`.
187    pub fn take_sound(&mut self) -> Option<String> {
188        self.sound.take()
189    }
190
191    /// Attach a host service, clearing the previous account's alerts and log.
192    pub fn set_service(&mut self, service: Arc<dyn Alerts>) {
193        self.sound = None;
194        self.listener = None;
195        self.service = Some(service);
196        self.alerts.clear();
197        self.log.clear();
198        self.error = None;
199        self.pending = 0;
200        self.draft_saving = false;
201        self.inbox = Arc::default();
202        self.watch();
203    }
204
205    fn watch(&mut self) {
206        self.generation += 1;
207        let (listener, events) = Listener::new(&self.inbox, self.generation);
208        self.listener = Some(listener);
209        if let Some(service) = &self.service {
210            service.watch(events);
211        }
212    }
213
214    /// The latest alerts supplied by the host.
215    pub fn alerts(&self) -> &[Alert] {
216        &self.alerts
217    }
218
219    /// The most recent 10,000 host triggers in arrival order.
220    pub fn log(&self) -> &VecDeque<AlertTrigger> {
221        &self.log
222    }
223
224    /// Save an alert from the shared dialog. The list changes only after a host push.
225    pub fn save(&mut self, alert: Alert) {
226        if !alert.is_valid() {
227            self.error = Some(FeedError::InvalidData(
228                "Alert contains invalid conditions".into(),
229            ));
230            return;
231        }
232        self.save_with(alert, 0);
233    }
234
235    /// Send `alert` and say whether a service took it; its reply arrives under `key`.
236    fn save_with(&mut self, mut alert: Alert, key: u64) -> bool {
237        let Some(service) = &self.service else {
238            self.error = Some(FeedError::NotSupported);
239            return false;
240        };
241        if let Some((available, timeframe)) = &self.inputs {
242            if let Some(inputs) = &mut alert.inputs {
243                inputs.add_sources_from(available);
244            } else if alert.id.is_none() {
245                let mut inputs = available.clone();
246                alert.timeframe = alert.timeframe.or(*timeframe);
247                if alert.symbol != self.symbol
248                    || alert.timeframe != *timeframe
249                    || alert.watchlist.is_some()
250                {
251                    inputs.start = None;
252                    inputs.visible = None;
253                    inputs.count = inputs.count.max(1000);
254                }
255                alert.inputs = Some(Box::new(inputs));
256            }
257        }
258        self.pending += 1;
259        service.save(alert, self.inbox.reply(key));
260        true
261    }
262
263    /// Alerts ordered by `key`, reversed when `descending`; equal values keep arrival order.
264    fn sorted(&self) -> Vec<&Alert> {
265        let mut alerts: Vec<&Alert> = self.alerts.iter().filter(|a| a.id.is_some()).collect();
266        let rank = |a: &Alert| match a.status {
267            AlertStatus::Active => 0,
268            AlertStatus::Triggered => 1,
269            AlertStatus::Disabled => 2,
270            AlertStatus::Expired => 3,
271        };
272        alerts.sort_by(|a, b| {
273            let order = match self.sort {
274                AlertSortKey::Arrival => return std::cmp::Ordering::Equal,
275                AlertSortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
276                AlertSortKey::Symbol => a.symbol.to_lowercase().cmp(&b.symbol.to_lowercase()),
277                AlertSortKey::Status => rank(a).cmp(&rank(b)),
278            };
279            if self.descending {
280                order.reverse()
281            } else {
282                order
283            }
284        });
285        alerts
286    }
287
288    /// Drain service pushes and replies, recover a lagged stream, and append trigger notifications.
289    /// Render `toasts` once per frame with the host's other notifications.
290    pub fn poll(&mut self, ctx: &egui::Context, toasts: &mut Toasts) {
291        let repaint = ctx.clone();
292        self.inbox.set_waker(move || repaint.request_repaint());
293        if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
294            self.listener = None;
295            self.alerts.clear();
296            self.watch();
297        }
298        for (key, answer) in self.inbox.drain().answers {
299            if key == self.generation {
300                if let Ok(event) = answer.downcast::<AlertEvent>() {
301                    self.apply_event(ctx, *event, toasts);
302                }
303            } else if (key == 0 || key == DRAFT_REPLY)
304                && let Ok(result) = answer.downcast::<Result<(), FeedError>>()
305            {
306                self.pending = self.pending.saturating_sub(1);
307                self.draft_saving &= key != DRAFT_REPLY;
308                match *result {
309                    Ok(()) => {
310                        self.error = None;
311                        if key == DRAFT_REPLY {
312                            self.draft =
313                                Alert::new(self.symbol.clone(), AlertCondition::Crossing(0.0));
314                        }
315                    }
316                    Err(error) => self.error = Some(error),
317                }
318            }
319        }
320    }
321
322    fn apply_event(&mut self, ctx: &egui::Context, event: AlertEvent, toasts: &mut Toasts) {
323        match event {
324            AlertEvent::Upsert(alert) => {
325                if alert.id.is_none() {
326                    return;
327                }
328                if let Some(existing) = self.alerts.iter_mut().find(|a| a.id == alert.id) {
329                    *existing = alert;
330                } else {
331                    self.alerts.push(alert);
332                }
333            }
334            AlertEvent::Remove(id) => self.alerts.retain(|a| a.id.as_ref() != Some(&id)),
335            AlertEvent::Clear => self.alerts.clear(),
336            AlertEvent::TriggeredForSymbol {
337                id,
338                symbol,
339                time,
340                price,
341                message,
342            } => {
343                self.record_trigger(
344                    ctx,
345                    AlertTrigger {
346                        id,
347                        symbol,
348                        time,
349                        price,
350                        message,
351                    },
352                    toasts,
353                );
354            }
355            AlertEvent::Triggered { id, time, price } => {
356                let symbol = self
357                    .alerts
358                    .iter()
359                    .find(|a| a.id.as_ref() == Some(&id))
360                    .map(|a| a.symbol.clone())
361                    .unwrap_or_default();
362                self.record_trigger(
363                    ctx,
364                    AlertTrigger {
365                        id,
366                        symbol,
367                        time,
368                        price,
369                        message: String::new(),
370                    },
371                    toasts,
372                );
373            }
374        }
375    }
376
377    fn record_trigger(
378        &mut self,
379        ctx: &egui::Context,
380        mut trigger: AlertTrigger,
381        toasts: &mut Toasts,
382    ) {
383        let AlertTrigger {
384            id,
385            symbol,
386            message,
387            ..
388        } = &trigger;
389        let title = l10n::format_text(
390            ctx,
391            "Alert triggered: {subject}",
392            &[(
393                "subject",
394                if symbol.is_empty() {
395                    id.0.as_str()
396                } else {
397                    symbol.as_str()
398                },
399            )],
400        );
401        toasts.add(
402            Toast::info(if message.is_empty() {
403                title
404            } else {
405                format!("{title}\n{message}")
406            })
407            .duration(self.notification_seconds),
408        );
409        self.sound = self.sound_choice.clone();
410        let extra = toasts.len().saturating_sub(5);
411        toasts.toasts_mut().drain(..extra);
412        trigger.price = trigger.price.filter(|p| p.is_finite());
413        self.log.push_back(trigger);
414        if self.log.len() > 10_000 {
415            self.log.pop_front();
416        }
417    }
418
419    /// Export the retained trigger log. Text fields are quoted and embedded quotes doubled.
420    pub fn log_csv(&self) -> String {
421        let mut csv = String::from("id,symbol,time,price,message\r\n");
422        for trigger in &self.log {
423            let id = trigger.id.0.replace('"', "\"\"");
424            let symbol = trigger.symbol.replace('"', "\"\"");
425            let price = trigger.price.map(|p| p.to_string()).unwrap_or_default();
426            let message = trigger.message.replace('"', "\"\"");
427            csv.push_str(&format!(
428                "\"{id}\",\"{symbol}\",{},{},\"{message}\"\r\n",
429                trigger.time.to_rfc3339(),
430                price
431            ));
432        }
433        csv
434    }
435
436    fn status_color(&self, ui: &Ui, status: AlertStatus) -> Color32 {
437        let theme = ui.active_theme();
438        match status {
439            AlertStatus::Active => theme.ui.success,
440            AlertStatus::Triggered => theme.ui.warning,
441            AlertStatus::Disabled => theme.ui.text_disabled,
442            AlertStatus::Expired => theme.ui.error,
443        }
444    }
445
446    fn sort_controls(&mut self, ui: &mut Ui) {
447        ui.horizontal_wrapped(|ui| {
448            ui.label(l10n::text(ui.ctx(), "Sort by"));
449            egui::ComboBox::from_id_salt("alert_sort")
450                .selected_text(l10n::text(
451                    ui.ctx(),
452                    match self.sort {
453                        AlertSortKey::Name => "Name",
454                        AlertSortKey::Symbol => "Symbol",
455                        AlertSortKey::Status => "Status",
456                        AlertSortKey::Arrival => "Arrival",
457                    },
458                ))
459                .show_ui(ui, |ui| {
460                    for (key, label) in [
461                        (AlertSortKey::Arrival, "Arrival"),
462                        (AlertSortKey::Name, "Name"),
463                        (AlertSortKey::Symbol, "Symbol"),
464                        (AlertSortKey::Status, "Status"),
465                    ] {
466                        ui.selectable_value(&mut self.sort, key, l10n::text(ui.ctx(), label));
467                    }
468                })
469                .response
470                .accessible_name(l10n::text(ui.ctx(), "Sort by"));
471            ui.add_enabled(
472                self.sort != AlertSortKey::Arrival,
473                egui::Checkbox::new(&mut self.descending, l10n::text(ui.ctx(), "Descending")),
474            );
475        });
476    }
477
478    /// The inline form for a price, study-output or volume alert; the shared validation and the
479    /// connected service decide whether it saves.
480    fn draft_form(&mut self, ui: &mut Ui) {
481        egui::CollapsingHeader::new(l10n::text(ui.ctx(), "Quick alert"))
482            .id_salt("alert_draft")
483            .show(ui, |ui| {
484                ui.horizontal(|ui| {
485                    ui.label(l10n::text(ui.ctx(), "Symbol"));
486                    ui.text_edit_singleline(&mut self.draft.symbol)
487                        .accessible_name(l10n::text(ui.ctx(), "Symbol"));
488                });
489                rule_form(
490                    ui,
491                    &mut self.draft.source,
492                    &mut self.draft.condition,
493                    &self.sources,
494                );
495                if self.draft_saving {
496                    ui.label(l10n::text(ui.ctx(), "Saving…"));
497                }
498                if ui
499                    .add_enabled(
500                        self.draft.is_valid() && !self.draft_saving,
501                        egui::Button::new(l10n::text(ui.ctx(), "Create alert")),
502                    )
503                    .clicked()
504                {
505                    self.draft_saving = self.save_with(self.draft.clone(), DRAFT_REPLY);
506                }
507            });
508        if self.pending > 0 {
509            ui.label(l10n::text(ui.ctx(), "Waiting for the alerts service…"));
510        }
511    }
512
513    /// Render the host's alerts or trigger log after `poll`, returning creation or export intents.
514    pub fn show(&mut self, ui: &mut Ui) -> AlertPanelAction {
515        let mut action = AlertPanelAction::None;
516        PanelHeader::new("Alerts").show(ui, |ui| {
517            if IconButton::new(&icons::PLUS)
518                .tooltip("New alert")
519                .show(ui)
520                .clicked()
521            {
522                action = AlertPanelAction::CreateAlert;
523            }
524        });
525        ui.horizontal(|ui| {
526            ui.selectable_value(&mut self.show_log, false, l10n::text(ui.ctx(), "Alerts"));
527            ui.selectable_value(&mut self.show_log, true, l10n::text(ui.ctx(), "Log"));
528        });
529        if let Some(error) = &self.error {
530            ui.colored_label(
531                ui.visuals().error_fg_color,
532                l10n::text(ui.ctx(), error.to_string()),
533            );
534        }
535        if self.show_log {
536            if Button::new("Export CSV…")
537                .enabled(!self.log.is_empty())
538                .show(ui)
539                .clicked()
540            {
541                action = AlertPanelAction::ExportLog(self.log_csv());
542            }
543            egui::ScrollArea::vertical()
544                .max_height(self.config.max_height)
545                .show(ui, |ui| {
546                    for trigger in self.log.iter().rev() {
547                        ui.label(format!(
548                            "{} · {}",
549                            trigger.symbol,
550                            trigger.time.format("%Y-%m-%d %H:%M:%S UTC")
551                        ));
552                        if let Some(price) = trigger.price {
553                            ui.label(l10n::text(ui.ctx(), price.to_string()));
554                        }
555                        if !trigger.message.is_empty() {
556                            ui.label(&trigger.message);
557                        }
558                        ui.separator();
559                    }
560                });
561            if self.log.is_empty() {
562                EmptyState::new("No triggers yet")
563                    .icon(&icons::ALERTS)
564                    .fill_height()
565                    .show(ui);
566            }
567        } else if self.alerts.is_empty() {
568            self.draft_form(ui);
569            if EmptyState::new("No alerts yet")
570                .icon(&icons::ALERTS)
571                .description("Create an alert with the + button or the chart menu.")
572                .action("Create alert")
573                .fill_height()
574                .show(ui)
575            {
576                action = AlertPanelAction::CreateAlert;
577            }
578        } else {
579            self.draft_form(ui);
580            self.sort_controls(ui);
581            let mut remove = None;
582            let mut toggle = None;
583            let mut duplicate = None;
584            egui::ScrollArea::vertical()
585                .max_height(self.config.max_height)
586                .show(ui, |ui| {
587                    for alert in self.sorted() {
588                        let Some(id) = &alert.id else { continue };
589                        ui.push_id(&id.0, |ui| {
590                            let subject = alert.watchlist.as_ref().map_or_else(
591                                || alert.symbol.clone(),
592                                |id| {
593                                    l10n::format_text(
594                                        ui.ctx(),
595                                        "Watchlist: {name}",
596                                        &[("name", id.as_str())],
597                                    )
598                                },
599                            );
600                            ui.label(RichText::new(l10n::text(ui.ctx(), subject)).strong());
601                            ui.label(l10n::text(ui.ctx(), alert.condition.to_string()));
602                            if !alert.additional_conditions.is_empty() {
603                                ui.label(l10n::format_text(
604                                    ui.ctx(),
605                                    "{0} additional conditions",
606                                    &[("0", format!("{}", alert.additional_conditions.len()))],
607                                ));
608                            }
609                            if !alert.name.is_empty() {
610                                ui.label(l10n::text(ui.ctx(), &alert.name));
611                            }
612                            let color = self.status_color(ui, alert.status);
613                            ui.colored_label(
614                                color,
615                                l10n::text(
616                                    ui.ctx(),
617                                    match alert.status {
618                                        AlertStatus::Active => "Active",
619                                        AlertStatus::Triggered => "Triggered",
620                                        AlertStatus::Disabled => "Disabled",
621                                        AlertStatus::Expired => "Expired",
622                                    },
623                                ),
624                            );
625                            if !alert.message.is_empty() {
626                                ui.label(l10n::text(ui.ctx(), &alert.message));
627                            }
628                            ui.horizontal_wrapped(|ui| {
629                                if Button::new("Edit").show(ui).clicked() {
630                                    action = AlertPanelAction::EditAlert(Box::new(alert.clone()));
631                                }
632                                let active = alert.status == AlertStatus::Active;
633                                if Button::new(if active { "Pause" } else { "Enable" })
634                                    .show(ui)
635                                    .clicked()
636                                {
637                                    let mut changed = alert.clone();
638                                    changed.status = if active {
639                                        AlertStatus::Disabled
640                                    } else {
641                                        AlertStatus::Active
642                                    };
643                                    toggle = Some(changed);
644                                }
645                                if Button::new("Duplicate").show(ui).clicked() {
646                                    let mut copy = alert.clone();
647                                    copy.id = None;
648                                    copy.status = AlertStatus::Active;
649                                    duplicate = Some(copy);
650                                }
651                                if IconButton::new(&icons::TRASH)
652                                    .tooltip("Remove alert")
653                                    .show(ui)
654                                    .clicked()
655                                {
656                                    remove = Some(id.clone());
657                                }
658                            });
659                            ui.separator();
660                        });
661                    }
662                });
663            if let Some(alert) = toggle {
664                self.save(alert);
665            }
666            if let Some(id) = remove
667                && let Some(service) = &self.service
668            {
669                self.pending += 1;
670                service.remove(id, self.inbox.reply(0));
671            }
672            if let Some(alert) = duplicate {
673                action = AlertPanelAction::DuplicateAlert(Box::new(alert));
674            }
675        }
676        action
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use crate::data::Reply;
684    use crate::model::AlertCondition;
685    use crate::services::Events;
686    use std::sync::Mutex;
687
688    #[derive(Default)]
689    struct Host {
690        events: Mutex<Option<Events<AlertEvent>>>,
691        reply: Mutex<Option<Reply<()>>>,
692    }
693
694    impl Alerts for Host {
695        fn watch(&self, events: Events<AlertEvent>) {
696            *self.events.lock().unwrap() = Some(events);
697        }
698        fn save(&self, _: Alert, reply: Reply<()>) {
699            *self.reply.lock().unwrap() = Some(reply);
700        }
701        fn remove(&self, _: AlertId, reply: Reply<()>) {
702            reply.send(Ok(()));
703        }
704    }
705
706    #[test]
707    fn only_host_pushes_change_the_list_and_triggers_survive_removal_and_export() {
708        let host = Arc::new(Host::default());
709        let mut panel = AlertsPanel::default();
710        panel.set_service(host.clone());
711        panel.set_notification_seconds(12.0);
712        let events = host.events.lock().unwrap().clone().unwrap();
713        let ctx = egui::Context::default();
714        let mut toasts = Toasts::new();
715        let mut frame = |panel: &mut AlertsPanel| {
716            let output = ctx.run_ui(egui::RawInput::default(), |ui| {
717                panel.poll(ui.ctx(), &mut toasts)
718            });
719            output.drop_without_applying_deltas();
720        };
721        let mut alert = Alert::new("S\"IM", AlertCondition::CrossingUp(12.5));
722        alert.id = Some(AlertId("host,\"1".into()));
723        panel.save(alert.clone());
724        host.reply.lock().unwrap().take().unwrap().send(Ok(()));
725        frame(&mut panel);
726        assert!(panel.alerts().is_empty());
727        events.push(AlertEvent::Upsert(alert.clone()));
728        events.push(AlertEvent::Triggered {
729            id: alert.id.clone().unwrap(),
730            time: "2026-10-23T12:00:00Z".parse().unwrap(),
731            price: Some(12.5),
732        });
733        frame(&mut panel);
734        assert_eq!(panel.alerts(), &[alert.clone()]);
735        assert_eq!(panel.alerts()[0].status, AlertStatus::Active);
736        assert_eq!(panel.log().len(), 1);
737        assert_eq!(
738            panel.log_csv(),
739            "id,symbol,time,price,message\r\n\"host,\"\"1\",\"S\"\"IM\",2026-10-23T12:00:00+00:00,12.5,\"\"\r\n"
740        );
741        alert.status = AlertStatus::Disabled;
742        events.push(AlertEvent::Upsert(alert.clone()));
743        frame(&mut panel);
744        assert_eq!(panel.alerts(), &[alert.clone()]);
745        panel.save(alert.clone());
746        host.reply
747            .lock()
748            .unwrap()
749            .take()
750            .unwrap()
751            .send(Err(FeedError::NotSupported));
752        frame(&mut panel);
753        assert_eq!(panel.error, Some(FeedError::NotSupported));
754        events.push(AlertEvent::Remove(alert.id.clone().unwrap()));
755        frame(&mut panel);
756        assert!(panel.alerts().is_empty());
757        assert_eq!(panel.log().len(), 1);
758        panel.save(alert);
759        for _ in 0..10_001 {
760            events.push(AlertEvent::Clear);
761        }
762        frame(&mut panel);
763        assert!(!events.is_active());
764        let renewed = host.events.lock().unwrap().clone().unwrap();
765        assert!(renewed.is_active());
766        host.reply
767            .lock()
768            .unwrap()
769            .take()
770            .unwrap()
771            .send(Err(FeedError::Unanswered));
772        frame(&mut panel);
773        assert_eq!(panel.error, Some(FeedError::Unanswered));
774        assert_eq!(panel.log().len(), 1);
775        assert_eq!(toasts.toasts()[0].duration, 12.0);
776        // The sound the user chose comes with the triggers received, and the message is shown in
777        // the popup and kept in the log and the export.
778        assert_eq!(panel.take_sound(), None);
779        panel.set_sound(Some("Bell".into()));
780        panel.apply_event(
781            &egui::Context::default(),
782            AlertEvent::TriggeredForSymbol {
783                id: AlertId("watchlist".into()),
784                symbol: "AAPL".into(),
785                time: Utc::now(),
786                price: Some(123.0),
787                message: "AAPL closed at \"123\"".into(),
788            },
789            &mut toasts,
790        );
791        assert!(toasts.toasts().iter().all(|toast| toast.duration == 12.0));
792        assert_eq!(
793            toasts.toasts().last().unwrap().message,
794            "Alert triggered: AAPL\nAAPL closed at \"123\""
795        );
796        assert_eq!(panel.log().back().unwrap().symbol, "AAPL");
797        assert_eq!(
798            panel.log().back().unwrap().message,
799            "AAPL closed at \"123\""
800        );
801        assert!(
802            panel
803                .log_csv()
804                .ends_with(",123,\"AAPL closed at \"\"123\"\"\"\r\n")
805        );
806        assert_eq!(panel.take_sound(), Some("Bell".into()));
807        assert_eq!(panel.take_sound(), None);
808        drop(panel);
809        assert!(!renewed.is_active());
810    }
811
812    #[test]
813    fn sorting_orders_by_key_in_either_direction_and_keeps_arrival_order_for_equal_values() {
814        let mut panel = AlertsPanel::default();
815        for (id, name, symbol, status) in [
816            ("1", "beta", "MSFT", AlertStatus::Disabled),
817            ("2", "Alpha", "aapl", AlertStatus::Active),
818            ("3", "beta", "AAPL", AlertStatus::Active),
819            ("4", "", "TSLA", AlertStatus::Expired),
820        ] {
821            let mut alert = Alert::new(symbol, AlertCondition::Above(1.0));
822            alert.id = Some(AlertId(id.into()));
823            alert.name = name.into();
824            alert.status = status;
825            panel.alerts.push(alert);
826        }
827        let ids = |panel: &AlertsPanel| {
828            panel
829                .sorted()
830                .iter()
831                .map(|a| a.id.as_ref().unwrap().0.as_str())
832                .collect::<String>()
833        };
834        for (key, descending, expected) in [
835            (AlertSortKey::Arrival, true, "1234"),
836            (AlertSortKey::Name, false, "4213"),
837            (AlertSortKey::Name, true, "1324"),
838            (AlertSortKey::Symbol, false, "2314"),
839            (AlertSortKey::Symbol, true, "4123"),
840            (AlertSortKey::Status, false, "2314"),
841            (AlertSortKey::Status, true, "4123"),
842        ] {
843            (panel.sort, panel.descending) = (key, descending);
844            assert_eq!(ids(&panel), expected, "{key:?} descending={descending}");
845        }
846    }
847
848    #[test]
849    fn inline_draft_stays_pending_until_the_service_answers_and_survives_a_failure() {
850        let host = Arc::new(Host::default());
851        let mut panel = AlertsPanel::default();
852        panel.set_service(host.clone());
853        let ctx = egui::Context::default();
854        let mut toasts = Toasts::new();
855        let mut frame = |panel: &mut AlertsPanel| {
856            ctx.run_ui(egui::RawInput::default(), |ui| {
857                panel.poll(ui.ctx(), &mut toasts)
858            })
859            .drop_without_applying_deltas();
860        };
861        panel.set_symbol("SIM");
862        panel.draft.condition = AlertCondition::CrossingUp(10.0);
863        panel.draft_saving = panel.save_with(panel.draft.clone(), DRAFT_REPLY);
864        assert_eq!((panel.pending, panel.draft_saving), (1, true));
865        host.reply
866            .lock()
867            .unwrap()
868            .take()
869            .unwrap()
870            .send(Err(FeedError::NotSupported));
871        frame(&mut panel);
872        assert_eq!((panel.pending, panel.draft_saving), (0, false));
873        assert_eq!(panel.error, Some(FeedError::NotSupported));
874        assert_eq!(panel.draft.symbol, "SIM");
875        panel.draft_saving = panel.save_with(panel.draft.clone(), DRAFT_REPLY);
876        host.reply.lock().unwrap().take().unwrap().send(Ok(()));
877        frame(&mut panel);
878        assert_eq!(panel.error, None);
879        assert_eq!(panel.draft.symbol, "SIM");
880        assert_eq!(panel.draft.condition, AlertCondition::Crossing(0.0));
881        assert!(panel.alerts().is_empty());
882    }
883
884    #[test]
885    fn default_alert_status_colours_follow_theme_changes() {
886        let panel = AlertsPanel::default();
887        let ctx = egui::Context::default();
888        for (_, theme) in crate::theme::presets() {
889            crate::theme::apply_to_egui(&ctx, &theme);
890            ctx.run_ui(Default::default(), |ui| {
891                for (status, expected) in [
892                    (AlertStatus::Active, theme.ui.success),
893                    (AlertStatus::Triggered, theme.ui.warning),
894                    (AlertStatus::Disabled, theme.ui.text_disabled),
895                    (AlertStatus::Expired, theme.ui.error),
896                ] {
897                    assert_eq!(panel.status_color(ui, status), expected);
898                }
899            })
900            .drop_without_applying_deltas();
901        }
902    }
903}