1use crate::localization as l10n;
6use std::cmp::Ordering;
7use std::collections::{HashMap, HashSet};
8use std::sync::Arc;
9
10use egui::text::LayoutJob;
11use egui::{
12 Align, Align2, EventFilter, Key, Label, Layout, Modifiers, RichText, Sense, Shape, Stroke,
13 TextStyle, Ui, pos2, vec2,
14};
15
16use super::market_details::WatchlistDetails;
17use super::symbol_details::detail_section;
18use super::{HEADER_HEIGHT, grouped, price_decimals, signed, weighted};
19use crate::data::inbox::Inbox;
20use crate::data::{CalendarEvent, NewsStory};
21use crate::icons::icons;
22use crate::model::Quote;
23use crate::painting::symbol_mark::paint_symbol_mark;
24use crate::scales::StudyFormat;
25use crate::services::{Listener, Quotes, ShareKind, Sharing};
26use crate::theme::ThemeExt;
27use crate::ui::share_controls::ShareControls;
28use crate::ui_kit::{EmptyState, IconButton};
29
30const LIST_ROW_HEIGHT: f32 = 30.0;
32const MARK_SIZE: f32 = 18.0;
34
35pub use crate::model::{Watchlist, WatchlistFlag, WatchlistItem};
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum WatchlistColumn {
41 Symbol,
43 Last,
45 Change,
47 ChangePercent,
49 Volume,
51 ExtendedLast,
53 ExtendedChange,
55 ExtendedChangePercent,
57}
58
59impl WatchlistColumn {
60 pub const DEFAULT: [Self; 3] = [Self::Last, Self::Change, Self::ChangePercent];
62
63 pub const WIDE: [Self; 7] = [
65 Self::Last,
66 Self::Change,
67 Self::ChangePercent,
68 Self::Volume,
69 Self::ExtendedLast,
70 Self::ExtendedChange,
71 Self::ExtendedChangePercent,
72 ];
73
74 fn label(self) -> &'static str {
75 match self {
76 Self::Symbol => "Symbol",
77 Self::Last => "Last",
78 Self::Change => "Chg",
79 Self::ChangePercent => "Chg%",
80 Self::Volume => "Vol",
81 Self::ExtendedLast => "Ext",
82 Self::ExtendedChange => "Ext chg",
83 Self::ExtendedChangePercent => "Ext chg%",
84 }
85 }
86
87 fn value(self, quote: &Quote) -> Option<f64> {
90 let extended = |change: fn(f64, f64) -> f64| {
91 quote
92 .extended
93 .map(|extended| change(extended.last, quote.last))
94 };
95 match self {
96 Self::Symbol => None,
97 Self::Last => Some(quote.last),
98 Self::Change => Some(quote.change()),
99 Self::ChangePercent => Some(quote.change_percent()),
100 Self::Volume => quote.volume,
101 Self::ExtendedLast => quote.extended.map(|extended| extended.last),
102 Self::ExtendedChange => extended(|last, regular| last - regular),
103 Self::ExtendedChangePercent => {
104 extended(|last, regular| (last - regular) / regular.abs() * 100.0)
105 }
106 }
107 .filter(|value| value.is_finite())
108 }
109}
110
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
113enum WatchlistSection {
114 #[default]
116 Overview,
117 Earnings,
119 Dividends,
121 News,
123}
124
125#[derive(Clone, Debug, PartialEq, Default)]
127#[non_exhaustive]
128pub enum WatchlistAction {
129 #[default]
131 None,
132 SelectSymbol(String),
134 AddSymbol,
137 AdvancedView,
140 CompactView,
142 Import,
144 Export {
147 name: String,
149 text: String,
151 },
152}
153
154struct RowDrag(egui::Id, usize);
156
157pub struct WatchlistPanel {
175 lists: Vec<Watchlist>,
176 active: usize,
177 quotes: HashMap<String, Quote>,
178 decimals: HashMap<String, u8>,
180 marks: HashMap<String, egui::ImageSource<'static>>,
182 columns: Vec<WatchlistColumn>,
184 sort: Option<(WatchlistColumn, bool)>,
185 section: WatchlistSection,
186 flag_filter: Option<WatchlistFlag>,
188 selected: Option<String>,
189 service: Option<Arc<dyn Quotes>>,
190 inbox: Arc<Inbox>,
191 share: ShareControls,
192 listener: Option<Listener>,
193 generation: u64,
195 subscribed: Vec<String>,
197}
198
199impl Default for WatchlistPanel {
200 fn default() -> Self {
201 Self::new(Vec::new())
202 }
203}
204
205impl WatchlistPanel {
206 pub fn new(mut lists: Vec<Watchlist>) -> Self {
208 if lists.is_empty() {
209 lists.push(Watchlist::new("Watchlist"));
210 }
211 Self {
212 lists,
213 active: 0,
214 quotes: HashMap::new(),
215 decimals: HashMap::new(),
216 marks: HashMap::new(),
217 columns: WatchlistColumn::DEFAULT.to_vec(),
218 sort: None,
219 section: WatchlistSection::default(),
220 flag_filter: None,
221 selected: None,
222 service: None,
223 inbox: Arc::default(),
224 share: ShareControls::default(),
225 listener: None,
226 generation: 0,
227 subscribed: Vec::new(),
228 }
229 }
230
231 pub fn set_columns(&mut self, columns: impl IntoIterator<Item = WatchlistColumn>) {
235 self.columns = columns
236 .into_iter()
237 .filter(|column| *column != WatchlistColumn::Symbol)
238 .collect();
239 if self.sort.is_some_and(|(column, _)| {
240 column != WatchlistColumn::Symbol && !self.columns.contains(&column)
241 }) {
242 self.sort = None;
243 }
244 }
245
246 pub fn flag_filter(&self) -> Option<WatchlistFlag> {
248 self.flag_filter
249 }
250
251 pub fn set_flag_filter(&mut self, flag: Option<WatchlistFlag>) {
254 self.flag_filter = flag;
255 }
256
257 pub fn lists(&self) -> &[Watchlist] {
259 &self.lists
260 }
261
262 pub fn symbols(&self) -> impl Iterator<Item = &str> {
265 let symbols: Vec<&str> = match self.flag_filter {
266 Some(flag) => flagged_symbols(&self.lists, flag)
267 .into_iter()
268 .filter_map(|(list, item)| match &self.lists[list].items[item] {
269 WatchlistItem::Symbol { symbol, .. } => Some(symbol.as_str()),
270 WatchlistItem::Section(_) => None,
271 })
272 .collect(),
273 None => self.lists[self.active].symbols().collect(),
274 };
275 symbols.into_iter()
276 }
277
278 pub fn set_quote(&mut self, symbol: &str, quote: Quote) {
281 if !quote.last.is_finite()
282 || !quote.prev_close.is_finite()
283 || [quote.volume, quote.bid, quote.ask]
284 .into_iter()
285 .flatten()
286 .any(|value| !value.is_finite())
287 || quote
288 .extended
289 .is_some_and(|extended| !extended.last.is_finite())
290 || self
291 .quotes
292 .get(symbol)
293 .is_some_and(|previous| quote.time < previous.time)
294 {
295 return;
296 }
297 self.quotes.insert(symbol.to_owned(), quote);
298 }
299
300 pub fn quote(&self, symbol: &str) -> Option<&Quote> {
302 self.quotes.get(symbol)
303 }
304
305 pub fn set_price_decimals(&mut self, symbol: &str, decimals: u8) {
308 self.decimals.insert(symbol.to_owned(), decimals);
309 }
310
311 pub fn set_mark(&mut self, symbol: &str, mark: egui::ImageSource<'static>) {
314 self.marks.insert(symbol.to_owned(), mark);
315 }
316
317 pub fn set_service(&mut self, service: Arc<dyn Quotes>) {
319 self.service = Some(service);
320 self.listener = None;
321 }
322
323 pub fn set_sharing(&mut self, sharing: Arc<dyn Sharing>) {
327 self.share.set_service(sharing);
328 }
329
330 pub fn poll(&mut self, ctx: &egui::Context) {
333 if let Some((source, name)) = self.share.poll(ctx)
334 && let Some(list) = self.lists.iter().find(|list| list.name == source)
335 {
336 let mut copy = list.clone();
337 copy.name = name;
338 self.lists.push(copy);
339 }
340 let Some(service) = &self.service else {
341 return;
342 };
343 let repaint = ctx.clone();
344 self.inbox.set_waker(move || repaint.request_repaint());
345 let mut symbols: Vec<String> = self.symbols().map(str::to_owned).collect();
346 symbols.extend(self.selected.clone());
347 symbols.sort();
348 symbols.dedup();
349 let lagged = self.listener.as_ref().is_none_or(|l| !l.is_active());
350 if lagged || symbols != self.subscribed {
351 self.generation += 1;
352 let (listener, events) = Listener::new(&self.inbox, self.generation);
353 self.listener = Some(listener);
355 self.quotes.retain(|symbol, _| symbols.contains(symbol));
356 self.subscribed = symbols;
357 service.subscribe(&self.subscribed, events);
358 }
359 for (key, answer) in self.inbox.drain().answers {
360 if key == self.generation
361 && let Ok(event) = answer.downcast::<(String, Quote)>()
362 {
363 let (symbol, quote) = *event;
364 self.set_quote(&symbol, quote);
365 }
366 }
367 }
368
369 pub fn set_selected(&mut self, symbol: &str) {
371 self.selected = Some(symbol.to_owned());
372 }
373
374 pub fn add_symbol(&mut self, symbol: &str) -> bool {
377 self.lists[self.active].add_symbol(symbol)
378 }
379
380 pub fn add_symbol_to(&mut self, list: usize, symbol: &str) -> bool {
384 self.lists
385 .get_mut(list)
386 .is_some_and(|list| list.add_symbol(symbol))
387 }
388
389 pub fn import(&mut self, name: impl Into<String>, text: &str) {
391 self.lists.push(Watchlist::from_text(name, text));
392 self.active = self.lists.len() - 1;
393 }
394
395 pub fn show(&mut self, ui: &mut Ui) -> WatchlistAction {
397 self.show_with(ui, None)
398 }
399
400 pub fn show_wide(&mut self, ui: &mut Ui, details: &WatchlistDetails) -> WatchlistAction {
407 self.show_with(ui, Some(details))
408 }
409
410 fn show_with(&mut self, ui: &mut Ui, wide: Option<&WatchlistDetails>) -> WatchlistAction {
411 let mut action = WatchlistAction::None;
412 let shown_symbols: Vec<String> = self.symbols().map(str::to_owned).collect();
413 let Self {
414 lists,
415 share,
416 active,
417 quotes,
418 decimals,
419 marks,
420 columns: compact_columns,
421 sort,
422 section,
423 flag_filter,
424 selected,
425 ..
426 } = self;
427 let theme = ui.active_theme();
428 let (mut menu, mut more) = (None, None);
429 let header = vec2(ui.available_width(), HEADER_HEIGHT);
430 ui.allocate_ui_with_layout(header, Layout::right_to_left(Align::Center), |ui| {
431 ui.set_min_height(header.y);
432 ui.spacing_mut().item_spacing.x = theme.spacing.sm;
433 more = Some(
434 IconButton::new(&icons::UI_MORE_HORIZONTAL)
435 .tooltip("More")
436 .show(ui),
437 );
438 if IconButton::new(&icons::TABLE)
439 .tooltip(if wide.is_some() {
440 "Compact view"
441 } else {
442 "Advanced view"
443 })
444 .show(ui)
445 .clicked()
446 {
447 action = if wide.is_some() {
448 WatchlistAction::CompactView
449 } else {
450 WatchlistAction::AdvancedView
451 };
452 }
453 if IconButton::new(&icons::PLUS)
454 .tooltip("Add symbol")
455 .show(ui)
456 .clicked()
457 {
458 action = WatchlistAction::AddSymbol;
459 }
460 ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
461 let chevron = icons::CHEVRON_DOWN.as_image_square(16.0);
462 let name = match flag_filter {
463 Some(flag) => l10n::format_text(
464 ui.ctx(),
465 "{0} flagged",
466 &[("0", l10n::text(ui.ctx(), format!("{flag:?}")))],
467 ),
468 None => l10n::text(ui.ctx(), lists[*active].name.clone()),
469 };
470 let title = LayoutJob::single_section(
471 name,
472 weighted(theme.text.title, 700.0, theme.ui.text),
473 );
474 menu = Some(
475 ui.add(
476 egui::Button::new((title, chevron))
477 .frame_when_inactive(false)
478 .image_tint_follows_text_color(true)
479 .truncate(),
480 )
481 .on_hover_text(l10n::text(ui.ctx(), "Lists")),
482 );
483 });
484 });
485 let (Some(menu), Some(more)) = (menu, more) else {
486 return action;
487 };
488 egui::Popup::menu(&menu).show(|ui| {
489 for (index, list) in lists.iter().enumerate() {
490 if ui
491 .selectable_label(
492 flag_filter.is_none() && index == *active,
493 l10n::text(ui.ctx(), &list.name),
494 )
495 .clicked()
496 {
497 (*active, *flag_filter) = (index, None);
498 }
499 }
500 ui.separator();
501 ui.label(l10n::text(ui.ctx(), "Flagged symbols"));
502 ui.horizontal(|ui| {
503 for choice in WatchlistFlag::ALL {
504 let (swatch, response) =
505 ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
506 let translated_label = l10n::text(ui.ctx(), format!("{choice:?}"));
507 response.widget_info(|| {
508 egui::WidgetInfo::selected(
509 egui::Role::RadioButton,
510 response.enabled(),
511 *flag_filter == Some(choice),
512 &translated_label,
513 )
514 });
515 ui.painter().circle_filled(
516 swatch.center(),
517 if *flag_filter == Some(choice) {
518 8.0
519 } else {
520 6.0
521 },
522 choice.color(),
523 );
524 if response.on_hover_text(&translated_label).clicked() {
525 *flag_filter = (*flag_filter != Some(choice)).then_some(choice);
526 ui.close();
527 }
528 }
529 });
530 ui.separator();
531 ui.horizontal(|ui| {
532 ui.label(l10n::text(ui.ctx(), "Name"));
533 ui.text_edit_singleline(&mut lists[*active].name);
534 });
535 if ui.button(l10n::text(ui.ctx(), "New list")).clicked() {
536 lists.push(Watchlist::new(format!("Watchlist {}", lists.len() + 1)));
537 (*active, *flag_filter) = (lists.len() - 1, None);
538 }
539 if ui.button(l10n::text(ui.ctx(), "Duplicate list")).clicked() {
540 let copy = lists[*active].duplicate(lists.iter().map(|list| list.name.as_str()));
541 lists.push(copy);
542 (*active, *flag_filter) = (lists.len() - 1, None);
543 }
544 if ui
545 .add_enabled(
546 lists.len() > 1,
547 egui::Button::new(l10n::text(ui.ctx(), "Delete list")),
548 )
549 .clicked()
550 {
551 lists.remove(*active);
552 *active = active.saturating_sub(1);
553 }
554 });
555 egui::Popup::menu(&more).show(|ui| {
556 if ui.button(l10n::text(ui.ctx(), "Add section")).clicked() {
557 lists[*active]
558 .items
559 .push(WatchlistItem::Section("New section".to_owned()));
560 }
561 if ui.button(l10n::text(ui.ctx(), "Import list…")).clicked() {
562 action = WatchlistAction::Import;
563 }
564 if ui.button(l10n::text(ui.ctx(), "Export list…")).clicked() {
565 action = WatchlistAction::Export {
566 name: lists[*active].name.clone(),
567 text: lists[*active].to_text(),
568 };
569 }
570 share.show(ui, ShareKind::Watchlist, &lists[*active].name);
571 });
572 if let Some(details) = wide {
573 ui.horizontal(|ui| {
574 for (choice, label) in [
575 (WatchlistSection::Overview, "Overview"),
576 (WatchlistSection::Earnings, "Earnings"),
577 (WatchlistSection::Dividends, "Dividends"),
578 (WatchlistSection::News, "News"),
579 ] {
580 ui.selectable_value(section, choice, l10n::text(ui.ctx(), label));
581 }
582 });
583 if *section != WatchlistSection::Overview {
584 let focus: Vec<&str> = match selected.as_deref() {
586 Some(symbol) if shown_symbols.iter().any(|shown| shown == symbol) => {
587 vec![symbol]
588 }
589 _ => shown_symbols.iter().map(String::as_str).collect(),
590 };
591 if let Some(symbol) = detail_rows(ui, *section, details, &focus) {
592 *selected = Some(symbol.clone());
593 action = WatchlistAction::SelectSymbol(symbol);
594 }
595 return action;
596 }
597 }
598 let mut flagged = flag_filter.map(|flag| Watchlist {
601 name: String::new(),
602 items: flagged_symbols(lists, flag)
603 .into_iter()
604 .map(|(list, item)| lists[list].items[item].clone())
605 .collect(),
606 });
607 let filtered = flagged.is_some();
608 let list = match &mut flagged {
609 Some(view) => view,
610 None => &mut lists[*active],
611 };
612 let reorderable = sort.is_none() && !filtered;
613
614 let list_id = match flag_filter {
615 Some(flag) => ui.scope_id().with(("watchlist_flagged", *flag)),
616 None => ui.scope_id().with(("watchlist_rows", *active)),
617 };
618 let collapsed_id = |title: &str| list_id.with(("collapsed", title));
620 let collapsed: HashSet<usize> = ui.data(|data| {
621 (0..list.items.len())
622 .filter(|&i| match &list.items[i] {
623 WatchlistItem::Section(title) => {
624 data.get_temp(collapsed_id(title)).unwrap_or(false)
625 }
626 WatchlistItem::Symbol { .. } => false,
627 })
628 .collect()
629 });
630 let order = display_order(&list.items, quotes, *sort, &collapsed);
631 let arrows = EventFilter {
633 vertical_arrows: true,
634 ..Default::default()
635 };
636 let mut reveal = false;
637 let focused = ui
638 .memory(|m| m.focused())
639 .filter(|id| (0..list.items.len()).any(|i| list_id.with(i) == *id));
640 if let Some(focused) = focused {
641 ui.memory_mut(|m| m.set_focus_lock_filter(focused, arrows));
642 let step = ui.input_mut(|i| {
643 i.consume_key(Modifiers::NONE, Key::ArrowDown) as isize
644 - i.consume_key(Modifiers::NONE, Key::ArrowUp) as isize
645 });
646 let symbols: Vec<&str> = order
647 .iter()
648 .filter_map(|&i| match &list.items[i] {
649 WatchlistItem::Symbol { symbol, .. } => Some(symbol.as_str()),
650 WatchlistItem::Section(_) => None,
651 })
652 .collect();
653 if step != 0 && !symbols.is_empty() {
654 let next = match symbols.iter().position(|s| Some(*s) == selected.as_deref()) {
655 Some(at) => at.saturating_add_signed(step).min(symbols.len() - 1),
656 None => 0,
657 };
658 if selected.as_deref() != Some(symbols[next]) {
659 *selected = Some(symbols[next].to_owned());
660 action = WatchlistAction::SelectSymbol(symbols[next].to_owned());
661 reveal = true;
662 }
663 }
664 }
665
666 let font = TextStyle::Body.resolve(ui.style());
667 let small = TextStyle::Small.resolve(ui.style());
668 let shown_columns: &[WatchlistColumn] = if wide.is_some() {
672 &WatchlistColumn::WIDE
673 } else {
674 compact_columns
675 };
676 let numbers = shown_columns.len();
677 let columns = |rect: egui::Rect| {
678 let right = rect.right() - 4.0;
679 let number = ((rect.width() - 18.0).max(0.0) * 0.705 / numbers.max(1) as f32).min(96.0);
680 let edge = |k: usize| {
681 if k == 0 {
682 rect.left() + 10.0
683 } else {
684 right - (numbers + 1 - k) as f32 * number
685 }
686 };
687 (0..=numbers)
688 .map(|i| {
689 egui::Rect::from_min_max(
690 pos2(edge(i), rect.top()),
691 pos2((edge(i + 1) - 4.0).max(edge(i)), rect.bottom()),
692 )
693 })
694 .collect::<Vec<_>>()
695 };
696 let all_columns: Vec<WatchlistColumn> = std::iter::once(WatchlistColumn::Symbol)
697 .chain(shown_columns.iter().copied())
698 .collect();
699
700 let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
701 let cells = columns(header);
702 for (index, column) in all_columns.iter().copied().enumerate() {
703 let label = column.label();
704 let align = if index == 0 {
705 Align2::LEFT_CENTER
706 } else {
707 Align2::RIGHT_CENTER
708 };
709 let text = ui.painter().text(
710 egui::pos2(
711 if index == 0 {
712 cells[index].left()
713 } else {
714 cells[index].right()
715 },
716 header.center().y,
717 ),
718 align,
719 l10n::text(ui.ctx(), label),
720 font.clone(),
721 theme.ui.text_muted,
722 );
723 if let Some((sorted, descending)) = *sort
724 && sorted == column
725 {
726 let x = if index == 0 {
728 text.right() + 7.0
729 } else {
730 text.left() - 7.0
731 };
732 let (tip, base) = if descending { (3.0, -2.0) } else { (-3.0, 2.0) };
733 let y = text.center().y;
734 ui.painter().add(Shape::convex_polygon(
735 vec![
736 pos2(x - 3.5, y + base),
737 pos2(x + 3.5, y + base),
738 pos2(x, y + tip),
739 ],
740 theme.ui.text_muted,
741 Stroke::NONE,
742 ));
743 }
744 let response = ui
745 .interact(
746 text.expand(3.0),
747 list_id.with(("column", index)),
748 Sense::click(),
749 )
750 .on_hover_cursor(egui::CursorIcon::PointingHand);
751 let translated_label = l10n::text(ui.ctx(), label);
752 response.widget_info(|| {
753 egui::WidgetInfo::labeled(
754 egui::Role::ColumnHeader,
755 response.enabled(),
756 &translated_label,
757 )
758 });
759 if let Some((sorted, descending)) = *sort
760 && sorted == column
761 {
762 ui.ctx().accesskit_node_builder(response.id, |node| {
763 node.set_sort_direction(if descending {
764 egui::accesskit::SortDirection::Descending
765 } else {
766 egui::accesskit::SortDirection::Ascending
767 });
768 });
769 }
770 if response.clicked() {
771 *sort = match *sort {
772 Some((c, false)) if c == column => Some((column, true)),
773 Some((c, true)) if c == column => None,
774 _ => Some((column, false)),
775 };
776 }
777 }
778 ui.painter().hline(
779 header.x_range(),
780 header.bottom(),
781 Stroke::new(1.0, theme.ui.border),
782 );
783
784 if list.items.is_empty() {
785 let empty = if filtered {
786 EmptyState::new("No flagged symbols")
787 .description("Flag symbols from their right-click menu.")
788 } else {
789 EmptyState::new("This list is empty")
790 .description("Add symbols with the add button above.")
791 };
792 empty.icon(&icons::WATCHLIST).fill_height().show(ui);
793 return action;
794 }
795
796 let mut moved = None;
797 let mut removed = None;
798 egui::ScrollArea::vertical()
799 .auto_shrink([false, false])
800 .show(ui, |ui| {
801 ui.spacing_mut().item_spacing.y = 0.0;
802 for &index in &order {
803 let (rect, _) = ui.allocate_exact_size(
804 vec2(ui.available_width(), LIST_ROW_HEIGHT),
805 Sense::hover(),
806 );
807 let sense = if reorderable {
808 Sense::click_and_drag()
809 } else {
810 Sense::click()
811 };
812 let response = ui.interact(rect, list_id.with(index), sense);
813 if response.dragged() {
814 response.dnd_set_drag_payload(RowDrag(list_id, index));
815 }
816 if let Some(from) = response.dnd_hover_payload::<RowDrag>()
817 && from.0 == list_id
818 && reorderable
819 {
820 let y = if from.1 < index {
821 rect.bottom()
822 } else {
823 rect.top()
824 };
825 ui.painter()
826 .hline(rect.x_range(), y, Stroke::new(2.0, theme.ui.accent));
827 }
828 if let Some(from) = response.dnd_release_payload::<RowDrag>()
829 && from.0 == list_id
830 && reorderable
831 && from.1 < list.items.len()
832 {
833 moved = Some((from.1, index));
834 }
835 let cells = columns(rect);
836 let painter = ui.painter().clone();
837 match &mut list.items[index] {
838 WatchlistItem::Section(title) => {
839 let is_collapsed = collapsed.contains(&index);
840 let translated_label = l10n::text(ui.ctx(), &*title);
841 response.widget_info(|| {
842 egui::WidgetInfo::labeled(
843 egui::Role::Button,
844 response.enabled(),
845 &translated_label,
846 )
847 });
848 ui.ctx().accesskit_node_builder(response.id, |node| {
849 node.set_expanded(!is_collapsed)
850 });
851 if response.clicked() {
852 let id = collapsed_id(title);
853 ui.data_mut(|data| data.insert_temp(id, !is_collapsed));
854 }
855 if response.hovered() {
856 painter.rect_filled(rect, 0.0, theme.ui.btn_bg_hover);
857 }
858 let chevron = if is_collapsed {
859 &icons::CHEVRON_RIGHT
860 } else {
861 &icons::CHEVRON_DOWN
862 };
863 let icon = egui::Rect::from_center_size(
864 pos2(rect.left() + 16.0, rect.center().y),
865 vec2(14.0, 14.0),
866 );
867 chevron
868 .as_image_tinted(icon.size(), theme.ui.text_secondary)
869 .paint_at(ui, icon);
870 ui.new_child(
871 egui::UiBuilder::new()
872 .max_rect(
873 rect.with_min_x(icon.right() + 6.0)
874 .with_max_x(rect.right() - 10.0),
875 )
876 .layout(Layout::left_to_right(Align::Center)),
877 )
878 .add(
879 Label::new(
880 RichText::new(l10n::text(ui.ctx(), title.to_uppercase()))
881 .font(small.clone())
882 .color(theme.ui.text_secondary),
883 )
884 .truncate()
885 .selectable(false),
886 );
887 response.context_menu(|ui| {
888 ui.text_edit_singleline(title);
889 if ui.button(l10n::text(ui.ctx(), "Remove section")).clicked() {
890 removed = Some(index);
891 ui.close();
892 }
893 });
894 }
895 WatchlistItem::Symbol { symbol, flag } => {
896 let is_selected = selected.as_deref() == Some(symbol.as_str());
897 let translated_label = symbol.as_str();
898 response.widget_info(|| {
899 egui::WidgetInfo::labeled(
900 egui::Role::ListBoxOption,
901 response.enabled(),
902 translated_label,
903 )
904 });
905 ui.ctx().accesskit_node_builder(response.id, |node| {
906 node.set_selected(is_selected)
907 });
908 if is_selected && reveal {
909 response.scroll_to_me(None);
910 }
911 if is_selected {
912 painter.rect_filled(rect, 0.0, theme.ui.btn_bg_active);
913 } else if response.hovered() {
914 painter.rect_filled(rect, 0.0, theme.ui.btn_bg_hover);
915 }
916 if let Some(flag) = flag {
917 let bar =
918 egui::Rect::from_min_size(rect.min, vec2(3.0, rect.height()));
919 painter.rect_filled(bar, 0.0, flag.color());
920 }
921 let mark = egui::Rect::from_min_size(
922 pos2(cells[0].left(), rect.center().y - MARK_SIZE / 2.0),
923 vec2(MARK_SIZE, MARK_SIZE),
924 );
925 paint_symbol_mark(
926 &painter,
927 mark,
928 symbol,
929 marks.get(symbol.as_str()).cloned(),
930 );
931 ui.new_child(
932 egui::UiBuilder::new()
933 .max_rect(cells[0].with_min_x(mark.right() + 6.0))
934 .layout(Layout::left_to_right(Align::Center)),
935 )
936 .add(
937 Label::new(
938 RichText::new(symbol.as_str())
939 .font(font.clone())
940 .color(theme.ui.text),
941 )
942 .truncate()
943 .selectable(false),
944 );
945 if let Some(quote) = quotes.get(symbol.as_str()) {
946 let decimals = (decimals.get(symbol.as_str()).copied())
947 .unwrap_or_else(|| price_decimals(quote.last));
948 let direction = |value: Option<f64>| match value {
949 Some(value) if value > 0.0 => theme.chart.candle_up,
950 Some(value) if value < 0.0 => theme.chart.candle_down,
951 _ => theme.ui.text,
952 };
953 for (at, column) in all_columns.iter().copied().enumerate().skip(1)
954 {
955 let text = match column.value(quote) {
956 None => "-".to_owned(),
957 Some(value) => match column {
958 WatchlistColumn::Volume => {
959 StudyFormat::Volume.format(value, None)
960 }
961 WatchlistColumn::ChangePercent
962 | WatchlistColumn::ExtendedChangePercent => {
963 format!("{}%", signed(value, 2))
964 }
965 WatchlistColumn::Change
966 | WatchlistColumn::ExtendedChange => {
967 signed(value, usize::from(decimals))
968 }
969 _ => grouped(value, usize::from(decimals)),
970 },
971 };
972 let color = match column {
973 WatchlistColumn::Last
974 | WatchlistColumn::Volume
975 | WatchlistColumn::ExtendedLast => theme.ui.text,
976 _ => direction(column.value(quote)),
977 };
978 ui.new_child(
979 egui::UiBuilder::new()
980 .max_rect(cells[at])
981 .layout(Layout::right_to_left(Align::Center)),
982 )
983 .add(
984 Label::new(
985 RichText::new(l10n::text(ui.ctx(), text))
986 .font(font.clone())
987 .color(color),
988 )
989 .truncate()
990 .selectable(false),
991 );
992 }
993 }
994 if response.clicked() {
995 *selected = Some(symbol.clone());
996 action = WatchlistAction::SelectSymbol(symbol.clone());
997 ui.memory_mut(|m| {
998 m.request_focus(response.id);
999 m.set_focus_lock_filter(response.id, arrows);
1000 });
1001 }
1002 response.context_menu(|ui| {
1003 ui.horizontal(|ui| {
1004 for choice in WatchlistFlag::ALL {
1005 let (swatch, response) = ui
1006 .allocate_exact_size(vec2(18.0, 18.0), Sense::click());
1007 let translated_label =
1008 l10n::text(ui.ctx(), format!("{choice:?}"));
1009 response.widget_info(|| {
1010 egui::WidgetInfo::selected(
1011 egui::Role::RadioButton,
1012 response.enabled(),
1013 *flag == Some(choice),
1014 &translated_label,
1015 )
1016 });
1017 ui.painter().circle_filled(
1018 swatch.center(),
1019 if *flag == Some(choice) { 8.0 } else { 6.0 },
1020 choice.color(),
1021 );
1022 if response.on_hover_text(&translated_label).clicked() {
1023 *flag = Some(choice);
1024 ui.close();
1025 }
1026 }
1027 });
1028 if ui
1029 .add_enabled(
1030 flag.is_some(),
1031 egui::Button::new(l10n::text(ui.ctx(), "Remove flag")),
1032 )
1033 .clicked()
1034 {
1035 *flag = None;
1036 ui.close();
1037 }
1038 if ui
1039 .button(l10n::text(ui.ctx(), "Remove from list"))
1040 .clicked()
1041 {
1042 removed = Some(index);
1043 ui.close();
1044 }
1045 });
1046 }
1047 }
1048 }
1049 });
1050 if let Some((from, to)) = moved {
1051 let item = list.items.remove(from);
1052 list.items.insert(to, item);
1053 }
1054 if let Some(index) = removed
1055 && !filtered
1056 {
1057 list.items.remove(index);
1058 }
1059 if let (Some(view), Some(filter)) = (flagged, *flag_filter) {
1060 apply_flag_view(lists, filter, &view.items, removed);
1061 }
1062 action
1063 }
1064}
1065
1066fn detail_rows(
1069 ui: &mut Ui,
1070 section: WatchlistSection,
1071 details: &WatchlistDetails,
1072 focus: &[&str],
1073) -> Option<String> {
1074 let in_focus = |symbol: &str| focus.contains(&symbol);
1075 let mut picked = None;
1076 egui::ScrollArea::both()
1077 .auto_shrink([false, false])
1078 .show(ui, |ui| {
1079 let events = |ui: &mut Ui, id: &str, events: &[CalendarEvent]| -> Option<String> {
1080 let mut clicked = None;
1081 let mut rows: Vec<&CalendarEvent> = events
1082 .iter()
1083 .filter(|event| event.symbol.as_deref().is_some_and(in_focus))
1084 .collect();
1085 rows.sort_by_key(|event| event.time);
1086 egui::Grid::new(id).striped(true).show(ui, |ui| {
1087 for heading in ["Date", "Symbol", "Name", "Actual", "Forecast", "Prior"] {
1088 ui.label(RichText::new(l10n::text(ui.ctx(), heading)).strong());
1089 }
1090 ui.end_row();
1091 for event in rows {
1092 ui.label(event.time.format("%Y-%m-%d").to_string());
1093 let symbol = event.symbol.clone().unwrap_or_default();
1094 if ui.link(&symbol).clicked() {
1095 clicked = Some(symbol);
1096 }
1097 ui.label(l10n::text(ui.ctx(), &event.title));
1098 for figure in [&event.actual, &event.forecast, &event.prior] {
1099 ui.label(figure.as_deref().unwrap_or("-"));
1100 }
1101 ui.end_row();
1102 }
1103 });
1104 clicked
1105 };
1106 let has_event = |events: &[CalendarEvent]| {
1107 events
1108 .iter()
1109 .any(|event| event.symbol.as_deref().is_some_and(in_focus))
1110 };
1111 match section {
1112 WatchlistSection::Overview => {}
1113 WatchlistSection::Earnings => detail_section(
1114 ui,
1115 "Earnings",
1116 &details.earnings,
1117 |list| !has_event(list),
1118 |ui, list| picked = events(ui, "watchlist_earnings", list),
1119 ),
1120 WatchlistSection::Dividends => detail_section(
1121 ui,
1122 "Dividends",
1123 &details.dividends,
1124 |list| !has_event(list),
1125 |ui, list| picked = events(ui, "watchlist_dividends", list),
1126 ),
1127 WatchlistSection::News => {
1128 let about =
1129 |story: &NewsStory| story.symbols.iter().any(|symbol| in_focus(symbol));
1130 detail_section(
1131 ui,
1132 "News",
1133 &details.news,
1134 |stories| !stories.iter().any(about),
1135 |ui, stories| {
1136 let mut rows: Vec<&NewsStory> =
1137 stories.iter().filter(|story| about(story)).collect();
1138 rows.sort_by_key(|story| std::cmp::Reverse(story.time));
1139 egui::Grid::new("watchlist_news")
1140 .striped(true)
1141 .show(ui, |ui| {
1142 for heading in ["Time", "Source", "Headline", "Symbols"] {
1143 ui.label(
1144 RichText::new(l10n::text(ui.ctx(), heading)).strong(),
1145 );
1146 }
1147 ui.end_row();
1148 for story in rows {
1149 ui.label(story.time.format("%Y-%m-%d %H:%M").to_string());
1150 ui.label(l10n::text(ui.ctx(), &story.source));
1151 ui.label(l10n::text(ui.ctx(), &story.headline));
1152 ui.horizontal(|ui| {
1153 for symbol in
1154 story.symbols.iter().filter(|s| in_focus(s))
1155 {
1156 if ui.link(symbol).clicked() {
1157 picked = Some(symbol.clone());
1158 }
1159 }
1160 });
1161 ui.end_row();
1162 }
1163 });
1164 },
1165 );
1166 }
1167 }
1168 });
1169 picked
1170}
1171
1172fn apply_flag_view(
1176 lists: &mut [Watchlist],
1177 filter: WatchlistFlag,
1178 view: &[WatchlistItem],
1179 removed: Option<usize>,
1180) {
1181 for (index, item) in view.iter().enumerate() {
1182 let WatchlistItem::Symbol { symbol, flag: new } = item else {
1183 continue;
1184 };
1185 let remove = removed == Some(index);
1186 if *new == Some(filter) && !remove {
1187 continue;
1188 }
1189 for list in lists.iter_mut() {
1190 list.items.retain_mut(|entry| match entry {
1191 WatchlistItem::Symbol { symbol: at, flag }
1192 if at == symbol && *flag == Some(filter) =>
1193 {
1194 *flag = *new;
1195 !remove
1196 }
1197 _ => true,
1198 });
1199 }
1200 }
1201}
1202
1203fn flagged_symbols(lists: &[Watchlist], flag: WatchlistFlag) -> Vec<(usize, usize)> {
1205 let mut seen = HashSet::new();
1206 let mut found = Vec::new();
1207 for (list, watchlist) in lists.iter().enumerate() {
1208 for (item, entry) in watchlist.items.iter().enumerate() {
1209 if let WatchlistItem::Symbol {
1210 symbol,
1211 flag: Some(flagged),
1212 } = entry
1213 && *flagged == flag
1214 && seen.insert(symbol.as_str())
1215 {
1216 found.push((list, item));
1217 }
1218 }
1219 }
1220 found
1221}
1222
1223fn display_order(
1227 items: &[WatchlistItem],
1228 quotes: &HashMap<String, Quote>,
1229 sort: Option<(WatchlistColumn, bool)>,
1230 collapsed: &HashSet<usize>,
1231) -> Vec<usize> {
1232 let mut order: Vec<usize> = (0..items.len()).collect();
1233 let mut hidden = false;
1234 order.retain(|&i| {
1235 if matches!(items[i], WatchlistItem::Section(_)) {
1236 hidden = collapsed.contains(&i);
1237 return true;
1238 }
1239 !hidden
1240 });
1241 let Some((column, descending)) = sort else {
1242 return order;
1243 };
1244 let symbol = |i: usize| match &items[i] {
1245 WatchlistItem::Symbol { symbol, .. } => symbol.as_str(),
1246 WatchlistItem::Section(_) => "",
1247 };
1248 let value = |i: usize| column.value(quotes.get(symbol(i))?);
1249 for run in order.split_mut(|&i| matches!(items[i], WatchlistItem::Section(_))) {
1250 run.sort_by(|&a, &b| {
1251 let ordering = if column == WatchlistColumn::Symbol {
1252 symbol(a).cmp(symbol(b))
1253 } else {
1254 match (value(a), value(b)) {
1255 (Some(a), Some(b)) => a.total_cmp(&b),
1256 (Some(_), None) => return Ordering::Less,
1257 (None, Some(_)) => return Ordering::Greater,
1258 (None, None) => Ordering::Equal,
1259 }
1260 };
1261 if descending {
1262 ordering.reverse()
1263 } else {
1264 ordering
1265 }
1266 });
1267 }
1268 order
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273 use super::*;
1274
1275 fn symbol(symbol: &str) -> WatchlistItem {
1276 WatchlistItem::Symbol {
1277 symbol: symbol.into(),
1278 flag: None,
1279 }
1280 }
1281
1282 #[test]
1283 fn text_keeps_sections_and_skips_blank_and_repeated_entries() {
1284 let list =
1285 Watchlist::from_text("Mine", " AAPL ,\n###Tech, MSFT\r\n,AAPL,,###Crypto,BTCUSD");
1286 assert_eq!(
1287 list.items,
1288 [
1289 symbol("AAPL"),
1290 WatchlistItem::Section("Tech".into()),
1291 symbol("MSFT"),
1292 WatchlistItem::Section("Crypto".into()),
1293 symbol("BTCUSD"),
1294 ]
1295 );
1296 assert_eq!(list.to_text(), "AAPL,###Tech,MSFT,###Crypto,BTCUSD");
1297 let mut list = list;
1298 for invalid in ["", " ", "AAPL", "A,B", "A\nB", "A\rB", "###Section"] {
1299 assert!(!list.add_symbol(invalid), "{invalid:?}");
1300 }
1301 assert!(list.add_symbol(" EURUSD "));
1302 assert_eq!(Watchlist::from_text("Mine", &list.to_text()), list);
1303 let mut titled = Watchlist::new("Titles");
1304 titled
1305 .items
1306 .push(WatchlistItem::Section("Rates, FX\nand more".into()));
1307 assert_eq!(
1308 Watchlist::from_text("Again", &titled.to_text()).items,
1309 [WatchlistItem::Section("Rates FX and more".into())]
1310 );
1311 }
1312
1313 #[test]
1316 fn sorting_keeps_sections_in_place_and_missing_values_last() {
1317 let items = vec![
1318 symbol("C"),
1319 symbol("A"),
1320 WatchlistItem::Section("Second".into()),
1321 symbol("NONE"),
1322 symbol("UP"),
1323 symbol("FLAT"),
1324 symbol("DOWN"),
1325 symbol("TIE"),
1326 ];
1327 let quote = |last, prev_close| Quote::new(last, prev_close, Default::default());
1328 let quotes = HashMap::from([
1329 ("A".to_owned(), quote(10.0, 10.0)),
1330 ("C".to_owned(), quote(30.0, 20.0)),
1331 ("UP".to_owned(), quote(110.0, 100.0)),
1332 ("FLAT".to_owned(), quote(50.0, 50.0)),
1333 ("DOWN".to_owned(), quote(90.0, 100.0)),
1334 ("TIE".to_owned(), quote(10.0, 10.0)),
1335 ]);
1336 let names = |sort, collapsed: &[usize]| {
1337 display_order(&items, "es, sort, &collapsed.iter().copied().collect())
1338 .into_iter()
1339 .map(|i| match &items[i] {
1340 WatchlistItem::Symbol { symbol, .. } => symbol.as_str(),
1341 WatchlistItem::Section(_) => "|",
1342 })
1343 .collect::<Vec<_>>()
1344 };
1345 use WatchlistColumn::*;
1346 assert_eq!(names(Some((Symbol, true)), &[2]), ["C", "A", "|"]);
1347 for (sort, expected) in [
1348 (None, ["C", "A", "|", "NONE", "UP", "FLAT", "DOWN", "TIE"]),
1349 (
1350 Some((Symbol, false)),
1351 ["A", "C", "|", "DOWN", "FLAT", "NONE", "TIE", "UP"],
1352 ),
1353 (
1354 Some((ChangePercent, false)),
1355 ["A", "C", "|", "DOWN", "FLAT", "TIE", "UP", "NONE"],
1356 ),
1357 (
1358 Some((ChangePercent, true)),
1359 ["C", "A", "|", "UP", "FLAT", "TIE", "DOWN", "NONE"],
1360 ),
1361 (
1362 Some((Last, true)),
1363 ["C", "A", "|", "UP", "DOWN", "FLAT", "TIE", "NONE"],
1364 ),
1365 ] {
1366 assert_eq!(names(sort, &[]), expected, "{sort:?}");
1367 }
1368 assert_eq!(quote(-37.63, -10.0).change_percent(), -276.3);
1369 }
1370
1371 #[test]
1375 fn duplicates_flag_views_and_quote_columns_follow_the_lists_and_quotes() {
1376 let flagged = |name: &str, flag| WatchlistItem::Symbol {
1377 symbol: name.into(),
1378 flag,
1379 };
1380 let mut tech = Watchlist::new("Tech");
1381 tech.items = vec![
1382 flagged("MSFT", Some(WatchlistFlag::Red)),
1383 WatchlistItem::Section("Chips".into()),
1384 flagged("NVDA", Some(WatchlistFlag::Blue)),
1385 flagged("AAPL", Some(WatchlistFlag::Red)),
1386 ];
1387 let copy = tech.duplicate(["Tech", "Tech copy"]);
1388 assert_eq!(copy.name, "Tech copy 2");
1389 assert_eq!(copy.items, tech.items);
1390
1391 let mut other = Watchlist::new("Other");
1392 other.items = vec![
1393 flagged("AAPL", Some(WatchlistFlag::Red)),
1394 flagged("XOM", Some(WatchlistFlag::Red)),
1395 flagged("BP", None),
1396 ];
1397 let mut panel = WatchlistPanel::new(vec![tech, other]);
1398 panel.set_flag_filter(Some(WatchlistFlag::Red));
1399 assert_eq!(
1400 panel.symbols().collect::<Vec<_>>(),
1401 ["MSFT", "AAPL", "XOM"],
1402 "the first list holding a symbol decides its place"
1403 );
1404 panel.set_flag_filter(None);
1405 assert_eq!(panel.symbols().count(), 3);
1406 assert!(panel.add_symbol_to(1, "SHEL") && !panel.add_symbol_to(1, "SHEL"));
1407 assert!(!panel.add_symbol_to(9, "SHEL"));
1408 let red = Some(WatchlistFlag::Red);
1411 apply_flag_view(
1412 &mut panel.lists,
1413 WatchlistFlag::Red,
1414 &[flagged("AAPL", None)],
1415 None,
1416 );
1417 apply_flag_view(
1418 &mut panel.lists,
1419 WatchlistFlag::Red,
1420 &[flagged("XOM", red)],
1421 Some(0),
1422 );
1423 panel.set_flag_filter(red);
1424 assert_eq!(panel.symbols().collect::<Vec<_>>(), ["MSFT"]);
1425 assert_eq!(
1426 panel.lists[1].symbols().collect::<Vec<_>>(),
1427 ["AAPL", "BP", "SHEL"]
1428 );
1429
1430 let time = chrono::Utc::now();
1431 let regular = Quote::new(100.0, 90.0, time).with_volume(2_000.0);
1432 let extended = |last| crate::model::ExtendedHoursQuote {
1433 session: crate::model::ExtendedSession::PostMarket,
1434 last,
1435 time,
1436 };
1437 let quote = regular.with_extended(extended(105.0));
1438 for (column, expected) in [
1440 (WatchlistColumn::Volume, Some(2_000.0)),
1441 (WatchlistColumn::ExtendedLast, Some(105.0)),
1442 (WatchlistColumn::ExtendedChange, Some(5.0)),
1443 (WatchlistColumn::ExtendedChangePercent, Some(5.0)),
1444 ] {
1445 assert_eq!(column.value("e), expected, "{column:?}");
1446 assert_eq!(
1447 column.value(&Quote::new(100.0, 90.0, time)),
1448 None,
1449 "{column:?}"
1450 );
1451 }
1452 assert_eq!(
1453 WatchlistColumn::ExtendedChangePercent
1454 .value(&Quote::new(0.0, 0.0, time).with_extended(extended(1.0))),
1455 None,
1456 "a change from a zero price is undefined"
1457 );
1458 panel.sort = Some((WatchlistColumn::Volume, false));
1459 panel.set_columns([WatchlistColumn::Last]);
1460 assert_eq!(panel.sort, None, "a sort by a hidden column is dropped");
1461 }
1462
1463 #[test]
1464 fn row_drops_only_reorder_their_own_unsorted_list() {
1465 for (foreign, sorted, expected) in [
1466 (false, false, "A,C,B"),
1467 (true, false, "A,B,C"),
1468 (false, true, "A,B,C"),
1469 ] {
1470 let ctx = egui::Context::default();
1471 let mut panel = WatchlistPanel::new(vec![Watchlist::from_text("List", "A,B,C")]);
1472 panel.sort = sorted.then_some((WatchlistColumn::Symbol, false));
1473 let frame = |panel: &mut WatchlistPanel, events| {
1474 let mut id = egui::Id::NULL;
1475 ctx.run_ui(
1476 egui::RawInput {
1477 events,
1478 ..Default::default()
1479 },
1480 |ui| {
1481 id = ui.scope_id().with(("watchlist_rows", 0_usize));
1482 panel.show(ui);
1483 },
1484 )
1485 .drop_without_applying_deltas();
1486 id
1487 };
1488 let id = frame(&mut panel, vec![]);
1489 let target = ctx.read_response(id.with(1_usize)).unwrap().rect.center();
1490 frame(
1491 &mut panel,
1492 vec![
1493 egui::Event::PointerMoved(target),
1494 egui::Event::PointerButton {
1495 pos: target,
1496 button: egui::PointerButton::Primary,
1497 pressed: true,
1498 modifiers: Modifiers::NONE,
1499 },
1500 ],
1501 );
1502 egui::DragAndDrop::set_payload(
1503 &ctx,
1504 RowDrag(if foreign { id.with("other") } else { id }, 2),
1505 );
1506 frame(
1507 &mut panel,
1508 vec![egui::Event::PointerButton {
1509 pos: target,
1510 button: egui::PointerButton::Primary,
1511 pressed: false,
1512 modifiers: Modifiers::NONE,
1513 }],
1514 );
1515 assert_eq!(panel.lists()[0].to_text(), expected);
1516 }
1517 }
1518
1519 #[test]
1521 fn arrow_keys_after_a_click_select_the_next_displayed_symbol() {
1522 let ctx = egui::Context::default();
1523 let mut panel = WatchlistPanel::new(vec![Watchlist::from_text("List", "B,###Section,A,C")]);
1524 panel.sort = Some((WatchlistColumn::Symbol, true));
1525 let frame = |panel: &mut WatchlistPanel, events: Vec<egui::Event>| {
1526 let mut action = WatchlistAction::None;
1527 let mut list_id = egui::Id::NULL;
1528 ctx.run_ui(
1529 egui::RawInput {
1530 screen_rect: Some(egui::Rect::from_min_size(
1531 egui::Pos2::ZERO,
1532 vec2(300.0, 600.0),
1533 )),
1534 events,
1535 ..Default::default()
1536 },
1537 |ui| {
1538 list_id = ui.scope_id().with(("watchlist_rows", 0_usize));
1539 action = panel.show(ui);
1540 },
1541 )
1542 .drop_without_applying_deltas();
1543 (action, list_id)
1544 };
1545 let (_, list_id) = frame(&mut panel, vec![]);
1546 let row = ctx.read_response(list_id.with(3)).unwrap().rect.center();
1548 let click = |pressed| egui::Event::PointerButton {
1549 pos: row,
1550 button: egui::PointerButton::Primary,
1551 pressed,
1552 modifiers: Modifiers::NONE,
1553 };
1554 frame(&mut panel, vec![egui::Event::PointerMoved(row)]);
1555 let (action, _) = frame(&mut panel, vec![click(true), click(false)]);
1556 assert_eq!(action, WatchlistAction::SelectSymbol("C".into()));
1557 let key = |key| egui::Event::Key {
1558 key,
1559 physical_key: None,
1560 pressed: true,
1561 repeat: false,
1562 modifiers: Modifiers::NONE,
1563 };
1564 for (pressed, expected) in [
1565 (Key::ArrowDown, "A"),
1566 (Key::ArrowUp, "C"),
1567 (Key::ArrowUp, "B"),
1568 ] {
1569 let (action, _) = frame(&mut panel, vec![key(pressed)]);
1570 assert_eq!(action, WatchlistAction::SelectSymbol(expected.into()));
1571 }
1572 }
1573
1574 #[test]
1580 fn quotes_follow_the_list_and_the_selected_symbol() {
1581 type Subscriptions = Vec<(Vec<String>, crate::services::Events<(String, Quote)>)>;
1582 #[derive(Default)]
1583 struct Recorder(std::sync::Mutex<Subscriptions>);
1584 impl Quotes for Recorder {
1585 fn subscribe(
1586 &self,
1587 symbols: &[String],
1588 events: crate::services::Events<(String, Quote)>,
1589 ) {
1590 self.0.lock().unwrap().push((symbols.to_vec(), events));
1591 }
1592 }
1593 let service = Arc::new(Recorder::default());
1594 let mut panel = WatchlistPanel::new(vec![Watchlist::from_text("List", "B,A")]);
1595 panel.set_selected("C");
1596 panel.set_service(service.clone());
1597 let ctx = egui::Context::default();
1598 let quote = |last| Quote::new(last, 1.0, Default::default());
1599 let stream = |i: usize| service.0.lock().unwrap()[i].clone();
1600 panel.poll(&ctx);
1601 let (symbols, first) = stream(0);
1602 assert_eq!(symbols, ["A", "B", "C"]);
1603 first.push(("A".into(), quote(2.0)));
1604 first.push(("C".into(), quote(3.0)));
1605 panel.poll(&ctx);
1606 assert_eq!(panel.quote("C").map(|q| q.last), Some(3.0));
1607
1608 let latest = Quote::new(
1610 102.0,
1611 100.0,
1612 chrono::DateTime::from_timestamp(2, 0).unwrap(),
1613 );
1614 first.push(("A".into(), latest.clone()));
1615 panel.poll(&ctx);
1616 for invalid in [
1617 Quote::new(
1618 101.0,
1619 100.0,
1620 chrono::DateTime::from_timestamp(1, 0).unwrap(),
1621 ),
1622 Quote::new(f64::NAN, 100.0, latest.time),
1623 Quote::new(103.0, f64::INFINITY, latest.time),
1624 latest.clone().with_volume(f64::NEG_INFINITY),
1625 latest.clone().with_bid_ask(f64::NAN, 103.0),
1626 latest.clone().with_bid_ask(101.0, f64::INFINITY),
1627 latest
1628 .clone()
1629 .with_extended(crate::model::ExtendedHoursQuote {
1630 session: crate::model::ExtendedSession::PostMarket,
1631 last: f64::NAN,
1632 time: latest.time,
1633 }),
1634 ] {
1635 first.push(("A".into(), invalid.clone()));
1636 panel.poll(&ctx);
1637 assert_eq!(panel.quote("A"), Some(&latest), "{invalid:?}");
1638 panel.set_quote("A", invalid);
1639 assert_eq!(panel.quote("A"), Some(&latest));
1640 }
1641 let corrected = latest.clone().with_bid_ask(-1.0, 0.0).with_volume(0.0);
1642 panel.set_quote("A", corrected.clone());
1643 assert_eq!(panel.quote("A"), Some(&corrected));
1644
1645 panel.set_selected("D");
1646 panel.poll(&ctx);
1647 let (symbols, second) = stream(1);
1648 assert_eq!(symbols, ["A", "B", "D"]);
1649 assert!(!first.is_active() && second.is_active());
1650 first.push(("A".into(), quote(9.0)));
1651 panel.poll(&ctx);
1652 assert_eq!(panel.quote("A").map(|q| q.last), Some(102.0));
1653 assert_eq!(panel.quote("C"), None);
1654 assert_eq!(service.0.lock().unwrap().len(), 2);
1655
1656 (0..=10_000).for_each(|_| second.push(("A".into(), quote(1.0))));
1657 panel.poll(&ctx);
1658 assert_eq!(stream(2).0, ["A", "B", "D"]);
1659 }
1660
1661 #[test]
1665 fn wide_view_keeps_selection_and_sort_and_states_each_section() {
1666 use crate::data::{CalendarEvent, CalendarKind};
1667 use crate::ui::widget_bar::MarketDetail;
1668 let ctx = egui::Context::default();
1669 let texts = |panel: &mut WatchlistPanel, details: Option<&WatchlistDetails>| {
1670 let output = ctx.run_ui(egui::RawInput::default(), |ui| {
1671 match details {
1672 Some(details) => panel.show_wide(ui, details),
1673 None => panel.show(ui),
1674 };
1675 });
1676 let texts: Vec<String> = output
1677 .shapes
1678 .iter()
1679 .filter_map(|shape| match &shape.shape {
1680 egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
1681 _ => None,
1682 })
1683 .collect();
1684 output.drop_without_applying_deltas();
1685 texts
1686 };
1687 let time: chrono::DateTime<chrono::Utc> = "2026-10-28T12:00:00Z".parse().unwrap();
1688 let event = |symbol: &str, title: &str| {
1689 let mut event = CalendarEvent::new(symbol, CalendarKind::Earnings, time, title);
1690 event.symbol = Some(symbol.into());
1691 event
1692 };
1693 let mut details = WatchlistDetails::default();
1694 let mut panel = WatchlistPanel::new(vec![Watchlist::from_text("List", "AAA,BBB")]);
1695 panel.set_quote("AAA", Quote::new(10.0, 9.0, time).with_volume(5.0));
1696 (panel.selected, panel.sort) = (Some("BBB".into()), Some((WatchlistColumn::Volume, true)));
1697 let compact = texts(&mut panel, None);
1698 assert!(
1699 !compact.contains(&"Vol".into()),
1700 "compact view omits volume"
1701 );
1702 assert!(texts(&mut panel, Some(&details)).contains(&"Vol".into()));
1703 panel.section = WatchlistSection::Earnings;
1704 assert!(texts(&mut panel, Some(&details)).contains(&"Not available".into()));
1705 details.earnings = MarketDetail::Loading;
1706 assert!(texts(&mut panel, Some(&details)).contains(&"Loading…".into()));
1707 details.earnings = MarketDetail::Failed("down".into());
1708 assert!(texts(&mut panel, Some(&details)).contains(&"down".into()));
1709 details.earnings =
1710 MarketDetail::Ready(vec![event("AAA", "Alpha Inc"), event("ZZZ", "Other")]);
1711 assert!(
1712 texts(&mut panel, Some(&details)).contains(&"No data".into()),
1713 "BBB has no rows"
1714 );
1715 panel.selected = None;
1716 let shown = texts(&mut panel, Some(&details));
1717 assert!(shown.contains(&"Alpha Inc".into()) && !shown.contains(&"Other".into()));
1718 panel.selected = Some("BBB".into());
1719 panel.section = WatchlistSection::Overview;
1720 texts(&mut panel, None);
1721 assert_eq!(
1722 (panel.selected.as_deref(), panel.sort),
1723 (Some("BBB"), Some((WatchlistColumn::Volume, true)))
1724 );
1725 }
1726}