1use crate::localization as l10n;
4use std::collections::BTreeMap;
5use std::sync::Arc;
6
7use chrono::{DateTime, Months, NaiveDate, NaiveTime, Offset, TimeZone, Timelike, Utc, Weekday};
8use egui::{Context, Ui};
9
10use super::{AlertDialogAction, AlertDialogConfig};
11use crate::data::{FeedError, inbox::Inbox};
12use crate::model::{
13 Alert, AlertChannelComparison, AlertComparison, AlertCondition, AlertFrequency, AlertRule,
14 AlertSource, NotificationSchedule, NotificationWindow, TimezoneId,
15};
16use crate::services::{Storage, StoreKind};
17use crate::ui_kit::FormGrid;
18use crate::ui_kit::dialog::{DialogFooter, dialog_header};
19use crate::ui_kit::modal::Modal;
20use crate::ui_kit::status_message::{MessageKind, status_message};
21
22pub struct AlertDialog {
24 pub config: AlertDialogConfig,
26 pub is_open: bool,
28 pub alert: Alert,
30 pub sources: Vec<(AlertSource, String)>,
32 pub watchlists: Vec<(String, String)>,
34 pub timezone: TimezoneId,
37 expires: bool,
39 expiry_date: String,
41 expiry_time: String,
43 scheduled: bool,
45 schedule_zone: String,
47 schedule_days: [bool; 7],
49 schedule_windows: Vec<[String; 2]>,
51 preset_name: String,
52 presets: BTreeMap<String, Alert>,
53 storage: Option<Arc<dyn Storage>>,
54 inbox: Arc<Inbox>,
55 pending: bool,
56 loaded: bool,
57 error: Option<String>,
58}
59
60impl Default for AlertDialog {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66impl AlertDialog {
67 pub fn new() -> Self {
69 Self {
70 config: AlertDialogConfig::default(),
71 is_open: false,
72 alert: Alert::new("", AlertCondition::Crossing(0.0)),
73 sources: Vec::new(),
74 watchlists: Vec::new(),
75 timezone: TimezoneId::utc(),
76 expires: false,
77 expiry_date: String::new(),
78 expiry_time: String::new(),
79 scheduled: false,
80 schedule_zone: String::new(),
81 schedule_days: [false; 7],
82 schedule_windows: Vec::new(),
83 preset_name: String::new(),
84 presets: BTreeMap::new(),
85 storage: None,
86 inbox: Arc::default(),
87 pending: false,
88 loaded: false,
89 error: None,
90 }
91 }
92
93 pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
95 self.inbox = Arc::default();
96 self.presets.clear();
97 self.error = None;
98 self.loaded = false;
99 self.pending = true;
100 storage.load(StoreKind::AlertPresets, "presets", self.inbox.reply(1));
101 self.storage = Some(storage);
102 }
103
104 pub fn open(&mut self, symbol: String, price: f64) {
106 self.edit(Alert::new(symbol, AlertCondition::Crossing(price)));
107 }
108
109 pub fn edit(&mut self, alert: Alert) {
111 if let Some(inputs) = &alert.inputs {
112 self.sources = inputs.sources.clone();
113 }
114
115 self.expires = alert.expires.is_some();
116 self.set_expiry_fields(alert.expires);
117 self.set_schedule_fields(alert.schedule.as_deref());
118 self.alert = alert;
119 self.is_open = true;
120 }
121
122 fn set_expiry_fields(&mut self, expiry: Option<DateTime<Utc>>) {
124 let local = expiry.map(|t| t.with_timezone(&self.timezone));
125 let format = |pattern| {
126 local
127 .as_ref()
128 .map_or_else(String::new, |t| t.format(pattern).to_string())
129 };
130 self.expiry_date = format("%Y-%m-%d");
131 self.expiry_time = format("%H:%M");
132 }
133
134 fn expiry(&self, now: DateTime<Utc>) -> Result<Option<DateTime<Utc>>, &'static str> {
137 if !self.expires {
138 return Ok(None);
139 }
140 let date = NaiveDate::parse_from_str(self.expiry_date.trim(), "%Y-%m-%d")
141 .map_err(|_| "Enter the expiry date as YYYY-MM-DD")?;
142 let time = NaiveTime::parse_from_str(self.expiry_time.trim(), "%H:%M")
143 .map_err(|_| "Enter the expiry time as HH:MM")?;
144 let saved = self.alert.expires.filter(|saved| {
147 let offset = saved.with_timezone(&self.timezone).offset().fix();
148 saved
149 .naive_utc()
150 .checked_add_offset(offset)
151 .is_some_and(|local| {
152 local.date() == date
153 && local.hour() == time.hour()
154 && local.minute() == time.minute()
155 })
156 });
157 let expiry = if let Some(saved) = saved {
158 saved
159 } else {
160 self.timezone
162 .from_local_datetime(&date.and_time(time))
163 .earliest()
164 .ok_or("The expiry time is skipped when clocks go forward")?
165 .with_timezone(&Utc)
166 };
167 if expiry <= now {
168 return Err("Choose an expiry after the current time");
169 }
170 Ok(Some(expiry))
171 }
172
173 fn set_schedule_fields(&mut self, schedule: Option<&NotificationSchedule>) {
175 self.scheduled = schedule.is_some();
176 self.schedule_zone =
177 schedule.map_or_else(|| self.timezone.name().into(), |s| s.timezone.name().into());
178 self.schedule_days = std::array::from_fn(|i| {
179 schedule.is_some_and(|s| s.days.contains(&Weekday::try_from(i as u8).unwrap()))
180 });
181 self.schedule_windows = schedule.map_or_else(Vec::new, |s| {
182 let text = |t: NaiveTime| t.format("%H:%M").to_string();
183 s.windows
184 .iter()
185 .map(|w| [text(w.start), text(w.end)])
186 .collect()
187 });
188 }
189
190 fn schedule(&self) -> Result<Option<NotificationSchedule>, &'static str> {
193 if !self.scheduled {
194 return Ok(None);
195 }
196 let timezone = TimezoneId::named(self.schedule_zone.trim())
197 .ok_or("Enter a known time zone, such as UTC or America/New_York")?;
198 let days: Vec<Weekday> = (0..7u8)
199 .filter(|&i| self.schedule_days[usize::from(i)])
200 .map(|i| Weekday::try_from(i).unwrap())
201 .collect();
202 let time = |text: &str| NaiveTime::parse_from_str(text.trim(), "%H:%M");
203 let windows = self
204 .schedule_windows
205 .iter()
206 .map(|[start, end]| match (time(start), time(end)) {
207 (Ok(start), Ok(end)) => Ok(NotificationWindow { start, end }),
208 _ => Err("Enter the window times as HH:MM"),
209 })
210 .collect::<Result<Vec<_>, _>>()?;
211 let schedule = NotificationSchedule {
212 timezone,
213 days,
214 windows,
215 };
216 if schedule.days.is_empty() {
217 Err("Select at least one weekday")
218 } else if schedule.windows.is_empty() {
219 Err("Add a notification window")
220 } else if !schedule.is_valid() {
221 Err("A window needs different start and end times")
222 } else {
223 Ok(Some(schedule))
224 }
225 }
226
227 pub fn close(&mut self) {
229 self.is_open = false;
230 }
231
232 pub fn sync_open_state(&mut self, should_be_open: bool) {
234 if !should_be_open {
235 self.close();
236 }
237 }
238
239 fn poll(&mut self, ctx: &Context) {
240 let repaint = ctx.clone();
241 self.inbox.set_waker(move || repaint.request_repaint());
242 for (key, answer) in self.inbox.drain().answers {
243 self.pending = false;
244 if key == 1 {
245 let result = *answer
246 .downcast::<Result<Option<String>, FeedError>>()
247 .expect("preset load");
248 match result {
249 Ok(document) => {
250 let result = document
251 .map(|doc| serde_json::from_str::<PresetDocument>(&doc))
252 .transpose();
253 match result {
254 Ok(doc) if doc.as_ref().is_none_or(|doc| doc.version == 1) => {
255 self.presets = doc.map(|doc| doc.presets).unwrap_or_default();
256 self.loaded = true;
257 }
258 Ok(_) => self.error = Some("Unsupported alert preset version".into()),
259 Err(error) => self.error = Some(error.to_string()),
260 }
261 }
262 Err(error) => self.error = Some(error.to_string()),
263 }
264 } else if let Err(error) = *answer
265 .downcast::<Result<(), FeedError>>()
266 .expect("preset save")
267 {
268 self.error = Some(error.to_string());
269 }
270 }
271 }
272
273 fn save_preset(&mut self) {
274 self.presets
275 .insert(self.preset_name.trim().into(), self.alert.clone());
276 let document = PresetDocument {
277 version: 1,
278 presets: self.presets.clone(),
279 };
280 match serde_json::to_string(&document) {
281 Ok(document) => {
282 if let Some(storage) = &self.storage {
283 self.pending = true;
284 self.error = None;
285 storage.save(
286 StoreKind::AlertPresets,
287 "presets",
288 document,
289 self.inbox.reply(2),
290 );
291 }
292 }
293 Err(error) => self.error = Some(error.to_string()),
294 }
295 }
296
297 pub fn show(&mut self, ctx: &Context) -> AlertDialogAction {
299 self.poll(ctx);
300 if !self.is_open {
301 return AlertDialogAction::None;
302 }
303 let mut action = AlertDialogAction::None;
304 let title = if self.alert.id.is_some() {
305 "Edit Alert"
306 } else {
307 "Create Alert"
308 };
309 let mut open = true;
310 Modal::new("create_alert", "")
311 .width(self.config.width + 32.0)
312 .height(self.config.height + 130.0)
313 .show(ctx, &mut open, |ui| {
314 let translated_label = l10n::text(ui.ctx(), title);
315 ui.response().widget_info(|| {
316 egui::WidgetInfo::labeled(egui::Role::Dialog, true, &translated_label)
317 });
318 ui.ctx()
319 .accesskit_node_builder(ui.response().id, |node| node.set_modal());
320 if dialog_header(ui, title) {
321 self.close();
322 action = AlertDialogAction::Cancel;
323 }
324 ui.separator();
325 let mut expiry = Ok(None);
326 let mut schedule = Ok(None);
327 let mut valid = false;
328 egui::ScrollArea::vertical()
329 .max_height(
330 (ui.available_height() - 65.0)
331 .min(self.config.height)
332 .max(0.0),
333 )
334 .show(ui, |ui| {
335 self.form(ui);
336 expiry = self.expiry(Utc::now());
337 schedule = self.schedule();
338 valid = self.alert.is_valid() && expiry.is_ok() && schedule.is_ok();
339 if !self.alert.is_valid() {
340 status_message(
341 ui,
342 MessageKind::Error,
343 "Check the subject, levels and lookback",
344 );
345 }
346 if let Err(error) = expiry {
347 status_message(ui, MessageKind::Error, error);
348 }
349 if let Err(error) = schedule {
350 status_message(ui, MessageKind::Error, error);
351 }
352 if let Some(error) = &self.error {
353 status_message(ui, MessageKind::Error, error);
354 }
355 });
356 let (save, cancel) = DialogFooter::new("Save alert")
357 .secondary("Cancel")
358 .primary_enabled(valid)
359 .show(ui);
360 if cancel {
361 self.close();
362 action = AlertDialogAction::Cancel;
363 }
364 if save {
365 self.alert.expires = expiry.unwrap();
366 self.alert.schedule = schedule.unwrap().map(Box::new);
367 action = AlertDialogAction::Create(Box::new(self.alert.clone()));
368 self.close();
369 }
370 });
371 if !open {
372 self.close();
373 action = AlertDialogAction::Cancel;
374 }
375 action
376 }
377
378 fn form(&mut self, ui: &mut Ui) {
379 let time_label = l10n::format_text(
380 ui.ctx(),
381 "Expiry time ({0})",
382 &[("0", self.timezone.name().to_owned())],
383 );
384 let label_width = [l10n::text(ui.ctx(), "Expiry date"), time_label.clone()]
385 .into_iter()
386 .map(|label| {
387 ui.painter()
388 .layout_no_wrap(
389 label,
390 egui::TextStyle::Body.resolve(ui.style()),
391 ui.visuals().text_color(),
392 )
393 .size()
394 .x
395 })
396 .fold(0.0, f32::max);
397 ui.spacing_mut().text_edit_width =
398 (ui.available_width() - label_width - ui.spacing().item_spacing.x).max(0.0);
399 FormGrid::new("alert_details").show(ui, |ui| {
400 ui.label(l10n::text(ui.ctx(), "Name"));
401 ui.text_edit_singleline(&mut self.alert.name)
402 .accessible_name(l10n::text(ui.ctx(), "Name"));
403 ui.end_row();
404 ui.label(l10n::text(ui.ctx(), "Symbol"));
405 ui.text_edit_singleline(&mut self.alert.symbol)
406 .accessible_name(l10n::text(ui.ctx(), "Symbol"));
407 ui.end_row();
408 ui.label(l10n::text(ui.ctx(), "Watchlist"));
409 egui::ComboBox::from_id_salt("alert_watchlist")
410 .width(ui.spacing().text_edit_width)
411 .truncate()
412 .selected_text(l10n::text(
413 ui.ctx(),
414 self.alert
415 .watchlist
416 .as_ref()
417 .and_then(|id| {
418 self.watchlists
419 .iter()
420 .find(|(key, _)| key == id)
421 .map(|(_, name)| name.as_str())
422 })
423 .unwrap_or(self.alert.watchlist.as_deref().unwrap_or("One symbol")),
424 ))
425 .show_ui(ui, |ui| {
426 ui.selectable_value(
427 &mut self.alert.watchlist,
428 None,
429 l10n::text(ui.ctx(), "One symbol"),
430 );
431 for (id, name) in &self.watchlists {
432 ui.selectable_value(
433 &mut self.alert.watchlist,
434 Some(id.clone()),
435 l10n::text(ui.ctx(), name),
436 );
437 }
438 })
439 .response
440 .accessible_name(l10n::text(ui.ctx(), "Watchlist"));
441 ui.end_row();
442 ui.label(l10n::text(ui.ctx(), "Frequency"));
443 egui::ComboBox::from_id_salt("alert_frequency")
444 .selected_text(l10n::text(ui.ctx(), self.alert.frequency.to_string()))
445 .show_ui(ui, |ui| {
446 for (value, label) in [
447 (AlertFrequency::Once, "Only once"),
448 (AlertFrequency::EveryTime, "Every time"),
449 (AlertFrequency::OncePerBar, "Once per bar"),
450 (AlertFrequency::OncePerBarClose, "Once per bar close"),
451 (AlertFrequency::OncePerMinute, "Once per minute"),
452 ] {
453 if matches!(self.alert.condition, AlertCondition::StrategyFills(_))
454 && !matches!(value, AlertFrequency::Once | AlertFrequency::EveryTime)
455 {
456 continue;
457 }
458 ui.selectable_value(
459 &mut self.alert.frequency,
460 value,
461 l10n::text(ui.ctx(), label),
462 );
463 }
464 })
465 .response
466 .accessible_name(l10n::text(ui.ctx(), "Frequency"));
467 ui.end_row();
468 ui.scope(|ui| {
470 ui.set_min_width(label_width);
471 ui.label(l10n::text(ui.ctx(), "Expiry"));
472 });
473 let mut no_expiry = !self.expires;
474 if ui
475 .checkbox(&mut no_expiry, l10n::text(ui.ctx(), "No expiry"))
476 .changed()
477 {
478 self.expires = !no_expiry;
479 if self.expires && self.expiry_date.is_empty() {
480 let now = Utc::now();
482 self.set_expiry_fields(now.checked_add_months(Months::new(1)).or(Some(now)));
483 }
484 }
485 ui.end_row();
486 if self.expires {
487 ui.label(l10n::text(ui.ctx(), "Expiry date"));
488 ui.add(
489 egui::TextEdit::singleline(&mut self.expiry_date)
490 .hint_text(l10n::text(ui.ctx(), "YYYY-MM-DD")),
491 )
492 .accessible_name(l10n::text(ui.ctx(), "Expiry date"));
493 ui.end_row();
494 ui.label(&time_label);
495 ui.add(
496 egui::TextEdit::singleline(&mut self.expiry_time)
497 .hint_text(l10n::text(ui.ctx(), "HH:MM")),
498 )
499 .accessible_name(time_label);
500 ui.end_row();
501 }
502 ui.label(l10n::text(ui.ctx(), "Notifications"));
503 let mut any_time = !self.scheduled;
504 if ui
505 .checkbox(&mut any_time, l10n::text(ui.ctx(), "Any time"))
506 .changed()
507 {
508 self.scheduled = !any_time;
509 }
510 ui.end_row();
511 if self.scheduled {
512 ui.label(l10n::text(ui.ctx(), "Time zone"));
513 ui.text_edit_singleline(&mut self.schedule_zone)
514 .accessible_name(l10n::text(ui.ctx(), "Time zone"));
515 ui.end_row();
516 ui.label(l10n::text(ui.ctx(), "Days"));
517 ui.horizontal_wrapped(|ui| {
518 for (selected, day) in self.schedule_days.iter_mut().zip(DAYS) {
519 ui.toggle_value(selected, l10n::text(ui.ctx(), day));
520 }
521 });
522 ui.end_row();
523 ui.label(l10n::text(ui.ctx(), "Windows"));
524 ui.vertical(|ui| {
525 let mut remove = None;
526 for (index, [start, end]) in self.schedule_windows.iter_mut().enumerate() {
527 ui.horizontal(|ui| {
528 ui.add(egui::TextEdit::singleline(start).desired_width(48.0))
529 .accessible_name(l10n::text(ui.ctx(), "Window start"));
530 ui.label("-");
531 ui.add(egui::TextEdit::singleline(end).desired_width(48.0))
532 .accessible_name(l10n::text(ui.ctx(), "Window end"));
533 if ui.button(l10n::text(ui.ctx(), "Remove")).clicked() {
534 remove = Some(index);
535 }
536 });
537 }
538 if let Some(index) = remove {
539 self.schedule_windows.remove(index);
540 }
541 if ui.button(l10n::text(ui.ctx(), "Add window")).clicked() {
542 self.schedule_windows.push(["09:00".into(), "17:00".into()]);
543 }
544 });
545 ui.end_row();
546 }
547 ui.label(l10n::text(ui.ctx(), "Message"));
548 ui.text_edit_multiline(&mut self.alert.message)
549 .accessible_name(l10n::text(ui.ctx(), "Message"));
550 ui.end_row();
551 });
552 ui.separator();
553 if !matches!(self.alert.condition, AlertCondition::StrategyFills(_)) {
554 ui.label(l10n::text(
555 ui.ctx(),
556 "All conditions must hold on the same bar",
557 ));
558 }
559 ui.push_id("first", |ui| {
560 rule_form(
561 ui,
562 &mut self.alert.source,
563 &mut self.alert.condition,
564 &self.sources,
565 )
566 });
567 let mut remove = None;
568 for (index, rule) in self.alert.additional_conditions.iter_mut().enumerate() {
569 ui.push_id(index, |ui| {
570 ui.separator();
571 rule_form(ui, &mut rule.source, &mut rule.condition, &self.sources);
572 if ui
573 .button(l10n::text(ui.ctx(), "Remove condition"))
574 .clicked()
575 {
576 remove = Some(index);
577 }
578 });
579 }
580 if let Some(index) = remove {
581 self.alert.additional_conditions.remove(index);
582 }
583 if !matches!(self.alert.condition, AlertCondition::StrategyFills(_))
584 && ui.button(l10n::text(ui.ctx(), "Add condition")).clicked()
585 {
586 self.alert.additional_conditions.push(AlertRule {
587 source: self.alert.source.clone(),
588 condition: self.alert.condition.clone(),
589 });
590 }
591 if self.storage.is_some() {
592 ui.separator();
593 ui.horizontal_wrapped(|ui| {
594 ui.label(l10n::text(ui.ctx(), "Preset"));
595 ui.text_edit_singleline(&mut self.preset_name)
596 .accessible_name(l10n::text(ui.ctx(), "Preset"));
597 if ui
598 .add_enabled(
599 self.loaded
600 && !self.pending
601 && self.alert.is_valid()
602 && !self.preset_name.trim().is_empty(),
603 egui::Button::new(l10n::text(ui.ctx(), "Save preset")),
604 )
605 .clicked()
606 && let (Ok(expiry), Ok(schedule)) = (self.expiry(Utc::now()), self.schedule())
607 {
608 self.alert.expires = expiry;
609 self.alert.schedule = schedule.map(Box::new);
610 self.save_preset();
611 }
612 });
613 let mut apply = None;
614 egui::ComboBox::from_id_salt("alert_presets")
615 .selected_text(l10n::text(ui.ctx(), "Apply preset"))
616 .show_ui(ui, |ui| {
617 for (name, alert) in &self.presets {
618 if ui
619 .selectable_label(false, l10n::text(ui.ctx(), name))
620 .clicked()
621 {
622 apply = Some(alert.clone());
623 }
624 }
625 })
626 .response
627 .accessible_name(l10n::text(ui.ctx(), "Apply preset"));
628 if let Some(mut preset) = apply {
629 preset.id.clone_from(&self.alert.id);
630 preset.status = self.alert.status;
631 self.edit(preset);
632 }
633 }
634 }
635}
636
637const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
638
639#[derive(serde::Serialize, serde::Deserialize)]
640struct PresetDocument {
641 version: u32,
642 presets: BTreeMap<String, Alert>,
643}
644
645fn source_picker(
646 ui: &mut Ui,
647 id: &str,
648 source: &mut AlertSource,
649 sources: &[(AlertSource, String)],
650) {
651 let label = sources
652 .iter()
653 .find(|(value, _)| value == source)
654 .map(|(_, name)| name.clone())
655 .unwrap_or_else(|| format!("{source:?}"));
656 egui::ComboBox::from_id_salt(id)
657 .width(ui.available_width().min(200.0))
658 .truncate()
659 .selected_text(l10n::text(ui.ctx(), label))
660 .show_ui(ui, |ui| {
661 if !id.starts_with("boundary_") {
662 ui.selectable_value(source, AlertSource::Price, l10n::text(ui.ctx(), "Price"));
663 ui.selectable_value(source, AlertSource::Volume, l10n::text(ui.ctx(), "Volume"));
664 }
665 for (value, name) in sources {
666 ui.selectable_value(source, value.clone(), l10n::text(ui.ctx(), name));
667 }
668 })
669 .response
670 .accessible_name(l10n::text(
671 ui.ctx(),
672 match id {
673 "other" => "Comparison source",
674 "boundary_a" => "First boundary",
675 "boundary_b" => "Second boundary",
676 _ => "Source",
677 },
678 ));
679}
680
681fn drawing_id(source: &AlertSource) -> Option<&str> {
682 match source {
683 AlertSource::Drawing(id) => Some(id.split('#').next().unwrap_or(id)),
684 _ => None,
685 }
686}
687
688fn drawing_channel(
690 source: &AlertSource,
691 condition: &AlertCondition,
692 sources: &[(AlertSource, String)],
693) -> Option<AlertCondition> {
694 if let AlertCondition::Channel { .. } = condition {
695 return Some(condition.clone());
696 }
697 let selected = match condition {
698 AlertCondition::Compare { other, .. } if drawing_id(other).is_some() => Some(other),
699 _ if drawing_id(source).is_some() => Some(source),
700 _ => sources
701 .iter()
702 .find(|(s, _)| {
703 drawing_id(s).is_some()
704 && sources
705 .iter()
706 .filter(|(other, _)| drawing_id(other) == drawing_id(s))
707 .count()
708 >= 2
709 })
710 .map(|(s, _)| s),
711 }?;
712 let mut levels = sources
713 .iter()
714 .filter(|(s, _)| drawing_id(s) == drawing_id(selected))
715 .rev();
716 let b = levels.next()?.0.clone();
717 let a = levels.next()?.0.clone();
718 Some(AlertCondition::Channel {
719 boundaries: [a, b],
720 comparison: AlertChannelComparison::Entering,
721 })
722}
723
724pub(crate) fn rule_form(
725 ui: &mut Ui,
726 source: &mut AlertSource,
727 condition: &mut AlertCondition,
728 sources: &[(AlertSource, String)],
729) {
730 ui.add_enabled_ui(
731 !matches!(
732 condition,
733 AlertCondition::VolumeAboveAverage { .. } | AlertCondition::StrategyFills(_)
734 ),
735 |ui| source_picker(ui, "source", source, sources),
736 );
737 let level = condition.level().unwrap_or(0.0);
738 let channel = drawing_channel(source, condition, sources);
739 let choices = [
740 (AlertCondition::Crossing(level), "Crossing"),
741 (AlertCondition::CrossingUp(level), "Crossing up"),
742 (AlertCondition::CrossingDown(level), "Crossing down"),
743 (AlertCondition::Above(level), "Greater than"),
744 (AlertCondition::Below(level), "Less than"),
745 (
746 AlertCondition::EnteringChannel {
747 lower: level,
748 upper: level + 1.0,
749 },
750 "Entering channel",
751 ),
752 (
753 AlertCondition::ExitingChannel {
754 lower: level,
755 upper: level + 1.0,
756 },
757 "Exiting channel",
758 ),
759 (
760 AlertCondition::InsideChannel {
761 lower: level,
762 upper: level + 1.0,
763 },
764 "Inside channel",
765 ),
766 (
767 AlertCondition::OutsideChannel {
768 lower: level,
769 upper: level + 1.0,
770 },
771 "Outside channel",
772 ),
773 (
774 AlertCondition::MovingBy {
775 value: 1.0,
776 bars: 1,
777 },
778 "Moving by value",
779 ),
780 (
781 AlertCondition::MovingByPercent {
782 percent: 1.0,
783 bars: 1,
784 },
785 "Moving by percent",
786 ),
787 (
788 AlertCondition::Compare {
789 other: AlertSource::Price,
790 comparison: AlertComparison::Crossing,
791 },
792 "Compare outputs",
793 ),
794 (
795 AlertCondition::VolumeAboveAverage {
796 multiplier: 2.0,
797 bars: 20,
798 },
799 "Volume above average",
800 ),
801 ]
802 .into_iter()
803 .chain(channel.map(|c| (c, "Drawing channel")))
804 .collect::<Vec<_>>();
805 let selected = choices
806 .iter()
807 .find(|(c, _)| std::mem::discriminant(c) == std::mem::discriminant(condition))
808 .map(|(_, name)| *name)
809 .unwrap_or(if matches!(condition, AlertCondition::StrategyFills(_)) {
810 "Strategy fills"
811 } else {
812 "Condition"
813 });
814 egui::ComboBox::from_id_salt("condition")
815 .selected_text(l10n::text(ui.ctx(), selected))
816 .show_ui(ui, |ui| {
817 for (value, label) in choices {
818 if ui
819 .selectable_label(label == selected, l10n::text(ui.ctx(), label))
820 .clicked()
821 && label != selected
822 {
823 if matches!(value, AlertCondition::VolumeAboveAverage { .. }) {
824 *source = AlertSource::Volume;
825 }
826 *condition = value;
827 }
828 }
829 })
830 .response
831 .accessible_name(l10n::text(ui.ctx(), "Condition"));
832 ui.horizontal_wrapped(|ui| match condition {
833 AlertCondition::StrategyFills(run) => {
834 ui.text_edit_singleline(run)
835 .accessible_name(l10n::text(ui.ctx(), "Strategy run"));
836 }
837 AlertCondition::Crossing(v)
838 | AlertCondition::CrossingUp(v)
839 | AlertCondition::CrossingDown(v)
840 | AlertCondition::Above(v)
841 | AlertCondition::Below(v) => {
842 ui.label(l10n::text(ui.ctx(), "Level"));
843 ui.add(egui::DragValue::new(v).speed(0.01))
844 .accessible_name(l10n::text(ui.ctx(), "Level"));
845 }
846 AlertCondition::EnteringChannel { lower, upper }
847 | AlertCondition::ExitingChannel { lower, upper }
848 | AlertCondition::InsideChannel { lower, upper }
849 | AlertCondition::OutsideChannel { lower, upper } => {
850 ui.label(l10n::text(ui.ctx(), "Lower / upper"));
851 ui.add(egui::DragValue::new(lower))
852 .accessible_name(l10n::text(ui.ctx(), "Lower"));
853 ui.add(egui::DragValue::new(upper))
854 .accessible_name(l10n::text(ui.ctx(), "Upper"));
855 }
856 AlertCondition::MovingBy { value, bars }
857 | AlertCondition::MovingByPercent {
858 percent: value,
859 bars,
860 } => {
861 ui.label(l10n::text(ui.ctx(), "Change / bars"));
862 ui.add(egui::DragValue::new(value))
863 .accessible_name(l10n::text(ui.ctx(), "Change"));
864 ui.add(egui::DragValue::new(bars).range(1..=u32::MAX))
865 .accessible_name(l10n::text(ui.ctx(), "Bars"));
866 }
867 AlertCondition::VolumeAboveAverage { multiplier, bars } => {
868 ui.label(l10n::text(ui.ctx(), "Multiplier / preceding bars"));
869 ui.add(egui::DragValue::new(multiplier).speed(0.1))
870 .accessible_name(l10n::text(ui.ctx(), "Multiplier"));
871 ui.add(egui::DragValue::new(bars).range(1..=u32::MAX))
872 .accessible_name(l10n::text(ui.ctx(), "Bars"));
873 }
874 AlertCondition::Channel {
875 boundaries,
876 comparison,
877 } => {
878 let drawings: Vec<_> = sources
879 .iter()
880 .filter(|(s, _)| drawing_id(s).is_some())
881 .cloned()
882 .collect();
883 ui.label(l10n::text(ui.ctx(), "Drawing boundaries"));
884 source_picker(ui, "boundary_a", &mut boundaries[0], &drawings);
885 source_picker(ui, "boundary_b", &mut boundaries[1], &drawings);
886 egui::ComboBox::from_id_salt("channel_comparison")
887 .selected_text(l10n::text(ui.ctx(), comparison.to_string()))
888 .show_ui(ui, |ui| {
889 for value in [
890 AlertChannelComparison::Entering,
891 AlertChannelComparison::Exiting,
892 AlertChannelComparison::Inside,
893 AlertChannelComparison::Outside,
894 ] {
895 ui.selectable_value(
896 comparison,
897 value,
898 l10n::text(ui.ctx(), value.to_string()),
899 );
900 }
901 })
902 .response
903 .accessible_name(l10n::text(ui.ctx(), "Channel condition"));
904 }
905 AlertCondition::Compare { other, comparison } => {
906 source_picker(ui, "other", other, sources);
907 egui::ComboBox::from_id_salt("comparison")
908 .selected_text(l10n::text(ui.ctx(), comparison.to_string()))
909 .show_ui(ui, |ui| {
910 for value in [
911 AlertComparison::Crossing,
912 AlertComparison::CrossingUp,
913 AlertComparison::CrossingDown,
914 AlertComparison::Above,
915 AlertComparison::Below,
916 ] {
917 ui.selectable_value(
918 comparison,
919 value,
920 l10n::text(ui.ctx(), value.to_string()),
921 );
922 }
923 })
924 .response
925 .accessible_name(l10n::text(ui.ctx(), "Comparison"));
926 }
927 });
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use crate::data::Reply;
934 use std::sync::Mutex;
935
936 #[derive(Default)]
937 struct Store(Mutex<Option<String>>);
938 impl Storage for Store {
939 fn list(&self, _: StoreKind, reply: Reply<Vec<String>>) {
940 reply.send(Ok(Vec::new()));
941 }
942 fn load(&self, _: StoreKind, _: &str, reply: Reply<Option<String>>) {
943 reply.send(Ok(self.0.lock().unwrap().clone()));
944 }
945 fn save(&self, _: StoreKind, _: &str, doc: String, reply: Reply<()>) {
946 *self.0.lock().unwrap() = Some(doc);
947 reply.send(Ok(()));
948 }
949 fn delete(&self, _: StoreKind, _: &str, reply: Reply<()>) {
950 reply.send(Ok(()));
951 }
952 }
953
954 #[test]
955 fn drawing_channel_keeps_selected_boundaries_through_edits() {
956 use egui_kittest::{Harness, kittest::Queryable};
957 for dark in [false, true] {
958 for width in [320.0, 960.0] {
959 let drawing = |id: &str| AlertSource::Drawing(id.into());
960 let sources = vec![
961 (drawing("a"), "Other line".into()),
962 (drawing("a#1"), "Other boundary".into()),
963 (drawing("b%23saved"), "Median".into()),
964 (drawing("b%23saved#1"), "Upper".into()),
965 (drawing("b%23saved#2"), "Lower".into()),
966 ];
967 let single = AlertCondition::Compare {
968 other: drawing("b%23saved"),
969 comparison: AlertComparison::Crossing,
970 };
971 let mut dialog = AlertDialog::new();
972 dialog.sources = sources;
973 dialog.edit(Alert::new("SAMPLE", single));
974 let mut h = Harness::builder()
975 .with_size(egui::vec2(width, 900.0))
976 .build_ui_state(
977 |ui, dialog: &mut AlertDialog| {
978 ui.ctx().set_visuals(if dark {
979 egui::Visuals::dark()
980 } else {
981 egui::Visuals::light()
982 });
983 dialog.form(ui)
984 },
985 dialog,
986 );
987 h.run();
988 h.get_by_label("Condition").click();
989 h.run();
990 h.get_by_label("Drawing channel").scroll_to_me();
991 h.run();
992 h.get_by_label("Drawing channel").click();
993 h.run();
994 let expected: AlertCondition = serde_json::from_value(serde_json::json!({"Channel": {
995 "boundaries": [{"Drawing":"b%23saved#1"},{"Drawing":"b%23saved#2"}], "comparison":"Entering"
996 }})).unwrap();
997 assert_eq!(h.state().alert.condition, expected);
998 let saved = serde_json::to_string(&h.state().alert).unwrap();
999 h.state_mut().edit(serde_json::from_str(&saved).unwrap());
1000 h.run();
1001 assert_eq!(h.state().alert.condition, expected);
1002 for label in ["First boundary", "Second boundary", "Channel condition"] {
1003 let rect = h.get_by_label(label).rect();
1004 assert!(
1005 rect.left() >= 0.0 && rect.right() <= width,
1006 "{label}: {rect:?} at {width}"
1007 );
1008 }
1009 }
1010 }
1011 }
1012
1013 #[test]
1014 fn form_controls_fill_the_dialog_width_for_each_source() {
1015 for (source, condition) in [
1016 (AlertSource::Price, AlertCondition::Above(100.0)),
1017 (
1018 AlertSource::Study {
1019 id: "study".into(),
1020 output: 0,
1021 },
1022 AlertCondition::Above(100.0),
1023 ),
1024 (AlertSource::Volume, AlertCondition::Above(100.0)),
1025 (
1026 AlertSource::Price,
1027 AlertCondition::StrategyFills("run".into()),
1028 ),
1029 ] {
1030 for width in [360.0, 560.0] {
1031 let ctx = Context::default();
1032 let mut dialog = AlertDialog::new();
1033 dialog.alert = Alert::new("SAMPLE", condition.clone());
1034 dialog.alert.source = source.clone();
1035 let mut form_width = 0.0;
1036 for _ in 0..3 {
1037 let output = ctx.run_ui(Default::default(), |ui| {
1038 ui.set_width(width);
1039 let form = ui.vertical(|ui| dialog.form(ui));
1040 form_width = form.response.rect.width();
1041 });
1042 if matches!(condition, AlertCondition::StrategyFills(_)) {
1043 assert!(
1044 !output.shapes.iter().any(|shape| matches!(
1045 &shape.shape, egui::Shape::Text(text)
1046 if text.galley.text() == "Add condition"
1047 )),
1048 "fill alerts cannot add bar conditions"
1049 );
1050 }
1051 output.drop_without_applying_deltas();
1052 }
1053 assert!(
1054 (form_width - width).abs() < 16.0,
1055 "{source:?}: {form_width} at {width}"
1056 );
1057 }
1058 }
1059 }
1060
1061 #[test]
1062 fn alert_form_and_actions_fit_narrow_windows_in_both_themes() {
1063 for (dark, fills) in [(false, false), (true, false), (false, true), (true, true)] {
1064 let ctx = Context::default();
1065 ctx.set_visuals(if dark {
1066 egui::Visuals::dark()
1067 } else {
1068 egui::Visuals::light()
1069 });
1070 let mut dialog = AlertDialog::new();
1071 dialog.set_storage(Arc::new(Store::default()));
1072 dialog.open("SAMPLE".into(), 100.0);
1073 dialog
1074 .watchlists
1075 .push(("list".into(), "A long host watchlist name".repeat(4)));
1076 dialog.alert.watchlist = Some("list".into());
1077 dialog.alert.frequency = AlertFrequency::OncePerBarClose;
1078 dialog.sources.push((
1079 AlertSource::Drawing("7".into()),
1080 "A long drawing name".repeat(4),
1081 ));
1082 dialog.alert.additional_conditions.push(AlertRule {
1083 source: AlertSource::Volume,
1084 condition: AlertCondition::VolumeAboveAverage {
1085 multiplier: 2.0,
1086 bars: 20,
1087 },
1088 });
1089 dialog.alert.additional_conditions.push(AlertRule {
1090 source: AlertSource::Price,
1091 condition: AlertCondition::Compare {
1092 other: AlertSource::Drawing("7".into()),
1093 comparison: AlertComparison::Crossing,
1094 },
1095 });
1096 dialog.sources.push((
1097 AlertSource::Drawing("7#1".into()),
1098 "A long second boundary name".repeat(4),
1099 ));
1100 dialog.alert.additional_conditions.push(AlertRule {
1101 source: AlertSource::Price,
1102 condition: AlertCondition::Channel {
1103 boundaries: [
1104 AlertSource::Drawing("7".into()),
1105 AlertSource::Drawing("7#1".into()),
1106 ],
1107 comparison: AlertChannelComparison::Entering,
1108 },
1109 });
1110 if fills {
1111 dialog.alert = Alert::new("SAMPLE", AlertCondition::StrategyFills("run".into()));
1112 }
1113 for size in [
1114 egui::vec2(1440.0, 900.0),
1115 egui::vec2(600.0, 360.0),
1116 egui::vec2(320.0, 450.0),
1117 ] {
1118 for invalid in [false, true] {
1119 dialog.expires = invalid;
1120 dialog.expiry_date = if invalid { "invalid" } else { "" }.into();
1121 for frame in 0..3 {
1122 let output = ctx.run_ui(
1123 egui::RawInput {
1124 screen_rect: Some(egui::Rect::from_min_size(
1125 egui::Pos2::ZERO,
1126 size,
1127 )),
1128 ..Default::default()
1129 },
1130 |ui| {
1131 dialog.show(ui.ctx());
1132 },
1133 );
1134 let save = output.shapes.iter().find_map(|shape| match &shape.shape {
1135 egui::Shape::Text(text) if text.galley.text() == "Save alert" => {
1136 Some((
1137 shape.clip_rect,
1138 egui::Rect::from_min_size(text.pos, text.galley.size()),
1139 ))
1140 }
1141 _ => None,
1142 });
1143 let message_visible = output.shapes.iter().any(|shape| matches!(
1144 &shape.shape, egui::Shape::Text(text) if text.galley.text() == "Message"
1145 && shape.clip_rect.contains_rect(egui::Rect::from_min_size(text.pos, text.galley.size()))
1146 ));
1147 output.drop_without_applying_deltas();
1148 if frame > 0 {
1149 if size.y == 900.0 {
1150 assert!(message_visible, "the default height fits the message row");
1151 }
1152 let (clip, button) = save.expect("save action is painted");
1153 assert!(
1154 clip.contains_rect(button),
1155 "save action is clipped: {button:?}"
1156 );
1157 }
1158 }
1159 let rect = ctx
1160 .memory(|m| m.area_rect(egui::Id::unique("create_alert")))
1161 .unwrap();
1162 assert!(
1163 rect.left() >= 0.0
1164 && rect.right() <= size.x
1165 && rect.top() >= 0.0
1166 && rect.bottom() <= size.y,
1167 "dark={dark}, invalid={invalid}, size={size:?}: {rect:?}"
1168 );
1169 }
1170 }
1171 }
1172 }
1173
1174 #[test]
1175 fn expiry_fields_resolve_in_the_entry_zone() {
1176 let at = |y, mo, d, h, mi| Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap();
1177 let check = |zone: TimezoneId, fields: Option<(&str, &str)>, expected| {
1178 let mut dialog = AlertDialog::new();
1179 dialog.timezone = zone.clone();
1180 dialog.expires = fields.is_some();
1181 (dialog.expiry_date, dialog.expiry_time) = fields
1182 .map(|(date, time)| (date.into(), time.into()))
1183 .unwrap_or_default();
1184 let expiry = dialog.expiry(at(2026, 1, 1, 0, 0));
1185 assert_eq!(expiry, expected, "{} {fields:?}", zone.name());
1186 if let (Ok(Some(saved)), Some(fields)) = (expiry, fields) {
1188 let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
1189 alert.expires = Some(saved);
1190 dialog.edit(alert);
1191 let shown = (dialog.expiry_date.as_str(), dialog.expiry_time.as_str());
1192 assert_eq!(shown, fields);
1193 }
1194 };
1195 let check_saved = |zone: TimezoneId, saved, edited: &str, expected| {
1196 let mut dialog = AlertDialog::new();
1197 dialog.timezone = zone;
1198 let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
1199 alert.expires = Some(saved);
1200 dialog.edit(alert);
1201 let now = at(2026, 1, 1, 0, 0);
1202 assert_eq!(
1203 dialog.expiry(now),
1204 Ok(Some(saved)),
1205 "unchanged fields retain the saved instant"
1206 );
1207 dialog.expiry_time = edited.into();
1208 assert_eq!(
1209 dialog.expiry(now),
1210 Ok(Some(expected)),
1211 "edited fields resolve in the entry zone"
1212 );
1213 };
1214 let utc = TimezoneId::utc;
1215 check_saved(
1216 utc(),
1217 at(2026, 3, 8, 2, 30) + chrono::Duration::milliseconds(12_345),
1218 "02:31",
1219 at(2026, 3, 8, 2, 31),
1220 );
1221 let east_of_utc = |hours: i32| TimezoneId::fixed_offset(hours * 3600).unwrap();
1222 for (zone, fields, expected) in [
1223 (utc(), None, Ok(None)),
1224 (
1225 utc(),
1226 Some(("2026-03-08", "02:30")),
1227 Ok(Some(at(2026, 3, 8, 2, 30))),
1228 ),
1229 (
1230 east_of_utc(-5),
1231 Some(("2026-03-08", "02:30")),
1232 Ok(Some(at(2026, 3, 8, 7, 30))),
1233 ),
1234 (
1235 east_of_utc(9),
1236 Some(("2026-03-08", "02:30")),
1237 Ok(Some(at(2026, 3, 7, 17, 30))),
1238 ),
1239 (
1240 utc(),
1241 Some(("08/03/2026", "02:30")),
1242 Err("Enter the expiry date as YYYY-MM-DD"),
1243 ),
1244 (
1245 utc(),
1246 Some(("2026-03-08", "2:30 pm")),
1247 Err("Enter the expiry time as HH:MM"),
1248 ),
1249 (
1250 utc(),
1251 Some(("2025-12-31", "23:59")),
1252 Err("Choose an expiry after the current time"),
1253 ),
1254 ] {
1255 check(zone, fields, expected);
1256 }
1257 for (hours, saved) in [
1260 (9, DateTime::<Utc>::MAX_UTC),
1261 (-9, DateTime::<Utc>::MIN_UTC),
1262 ] {
1263 let mut dialog = AlertDialog::new();
1264 dialog.timezone = east_of_utc(hours);
1265 let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
1266 alert.expires = Some(saved);
1267 dialog.edit(alert);
1268 dialog.expiry_date = "2030-01-01".into();
1269 dialog.expiry_time = "10:00".into();
1270 assert_eq!(
1271 dialog.expiry(at(2026, 1, 1, 0, 0)),
1272 Ok(Some(at(2030, 1, 1, (10 - hours) as u32, 0)))
1273 );
1274 }
1275 #[cfg(feature = "timezones")]
1276 {
1277 let new_york = || TimezoneId::named("America/New_York").unwrap();
1278 check_saved(
1279 new_york(),
1280 at(2026, 11, 1, 6, 30) + chrono::Duration::seconds(17),
1281 "01:31",
1282 at(2026, 11, 1, 5, 31),
1283 );
1284 for (fields, expected) in [
1287 (
1288 ("2026-03-08", "02:30"),
1289 Err("The expiry time is skipped when clocks go forward"),
1290 ),
1291 (("2026-11-01", "01:30"), Ok(Some(at(2026, 11, 1, 5, 30)))),
1292 (("2026-07-04", "11:45"), Ok(Some(at(2026, 7, 4, 15, 45)))),
1293 ] {
1294 check(new_york(), Some(fields), expected);
1295 }
1296 }
1297 }
1298
1299 #[test]
1300 fn schedule_fields_resolve_to_a_valid_schedule_or_name_the_problem() {
1301 let mut dialog = AlertDialog::new();
1302 assert_eq!(dialog.schedule(), Ok(None));
1303 dialog.scheduled = true;
1304 dialog.schedule_zone = "UTC".into();
1305 assert_eq!(dialog.schedule(), Err("Select at least one weekday"));
1306 dialog.schedule_days[4] = true;
1307 assert_eq!(dialog.schedule(), Err("Add a notification window"));
1308 dialog.schedule_windows = vec![["22:00".into(), "22:00".into()]];
1309 assert_eq!(
1310 dialog.schedule(),
1311 Err("A window needs different start and end times")
1312 );
1313 dialog.schedule_windows = vec![["22:00".into(), "2am".into()]];
1314 assert_eq!(dialog.schedule(), Err("Enter the window times as HH:MM"));
1315 dialog.schedule_windows = vec![["22:00".into(), "02:00".into()]];
1316 dialog.schedule_zone = "Mars/Olympus".into();
1317 assert!(dialog.schedule().is_err());
1318 dialog.schedule_zone = "UTC".into();
1319 let schedule = dialog.schedule().unwrap().unwrap();
1320 assert_eq!(schedule.days, [Weekday::Fri]);
1321 let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
1323 alert.schedule = Some(Box::new(schedule.clone()));
1324 let mut other = AlertDialog::new();
1325 other.edit(alert);
1326 assert_eq!(other.schedule(), Ok(Some(schedule)));
1327 }
1328
1329 #[test]
1330 fn presets_survive_a_new_dialog_and_unknown_versions_cannot_be_overwritten() {
1331 let store = Arc::new(Store::default());
1332 let ctx = Context::default();
1333 let mut dialog = AlertDialog::new();
1334 dialog.set_storage(store.clone());
1335 dialog.poll(&ctx);
1336 dialog.open("EURUSD".into(), 1.125);
1337 dialog.alert.name = "Breakout".into();
1338 dialog.alert.frequency = AlertFrequency::OncePerBarClose;
1339 dialog.preset_name = "Close".into();
1340 dialog.save_preset();
1341 dialog.poll(&ctx);
1342 let expected = dialog.alert.clone();
1343 let mut restored = AlertDialog::new();
1344 restored.set_storage(store.clone());
1345 restored.poll(&ctx);
1346 assert_eq!(restored.presets["Close"], expected);
1347 *store.0.lock().unwrap() = Some(r#"{"version":2,"presets":{}}"#.into());
1348 restored.set_storage(store);
1349 restored.poll(&ctx);
1350 assert!(!restored.loaded);
1351 assert!(restored.error.is_some());
1352 restored.open("AAPL".into(), 123.0);
1353 assert_eq!(restored.alert.condition, AlertCondition::Crossing(123.0));
1354 assert!(restored.alert.name.is_empty());
1355 }
1356}