Skip to main content

egui_charts/ui/widget_bar/panels/
watchlist.rs

1//! Host-fed watchlists: named lists of symbols with sections, colour flags, sorting and text
2//! import and export. The host's quote service supplies the quotes, and the host persists the
3//! lists.
4
5use 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
30/// Height of a watchlist row, which fits its symbol mark.
31const LIST_ROW_HEIGHT: f32 = 30.0;
32/// Diameter of a symbol's mark.
33const MARK_SIZE: f32 = 18.0;
34
35pub use crate::model::{Watchlist, WatchlistFlag, WatchlistItem};
36
37/// A sortable watchlist column.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum WatchlistColumn {
41    /// Sort by symbol.
42    Symbol,
43    /// Sort by last price.
44    Last,
45    /// Sort by price change.
46    Change,
47    /// Sort by percentage change.
48    ChangePercent,
49    /// Sort by the session's volume.
50    Volume,
51    /// Sort by the last price of the extended-hours session.
52    ExtendedLast,
53    /// Sort by the extended-hours price change from the regular last price.
54    ExtendedChange,
55    /// Sort by the extended-hours percentage change from the regular last price.
56    ExtendedChangePercent,
57}
58
59impl WatchlistColumn {
60    /// The columns after the symbol that the panel shows by default.
61    pub const DEFAULT: [Self; 3] = [Self::Last, Self::Change, Self::ChangePercent];
62
63    /// The columns after the symbol in the wide view: all of them.
64    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    /// The figure of `quote` the column shows and sorts by, or `None` when the quote lacks it.
88    /// Extended-hours changes are measured from the regular session's last price.
89    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/// A section of the wide watchlist.
112#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
113enum WatchlistSection {
114    /// The table of quotes.
115    #[default]
116    Overview,
117    /// Earnings of the shown symbols.
118    Earnings,
119    /// Dividends of the shown symbols.
120    Dividends,
121    /// News about the shown symbols.
122    News,
123}
124
125/// An action the host handles.
126#[derive(Clone, Debug, PartialEq, Default)]
127#[non_exhaustive]
128pub enum WatchlistAction {
129    /// No host action.
130    #[default]
131    None,
132    /// Show this symbol on the chart. The panel already marks it selected.
133    SelectSymbol(String),
134    /// Let the user pick a symbol, such as with symbol search, and pass it to
135    /// [`WatchlistPanel::add_symbol`].
136    AddSymbol,
137    /// Show the active list in the host's full-size view, drawn with
138    /// [`WatchlistPanel::show_wide`].
139    AdvancedView,
140    /// Return from the wide view to the compact panel, drawn with [`WatchlistPanel::show`].
141    CompactView,
142    /// Read a text file the user chooses and pass it to [`WatchlistPanel::import`].
143    Import,
144    /// Save `text`, the active list in the [`Watchlist::from_text`] format, to a file the user
145    /// chooses.
146    Export {
147        /// Display name.
148        name: String,
149        /// Exported watchlist text for the host to save or copy.
150        text: String,
151    },
152}
153
154/// The source list and row index, so separate panels cannot exchange row indices.
155struct RowDrag(egui::Id, usize);
156
157/// Watchlists fed by host quotes.
158///
159/// With a [`Quotes`] service attached through [`set_service`](Self::set_service), the panel
160/// subscribes to the [`symbols`](Self::symbols) of the active list and the selected symbol, and
161/// subscribes again whenever they change; call [`poll`](Self::poll) each frame, even while the
162/// panel is hidden. Without one, set quotes with [`set_quote`](Self::set_quote). Either way
163/// [`quote`](Self::quote) reads them back, so the selected symbol's quote can feed the symbol
164/// details panel, the symbol header and the order ticket. Keep the chart symbol selected with
165/// [`set_selected`](Self::set_selected).
166/// The list name in the header opens the list menu; beside it, the add button returns
167/// [`WatchlistAction::AddSymbol`], the advanced view button [`WatchlistAction::AdvancedView`],
168/// and the more button opens the actions on the active list. Clicking a symbol, or moving
169/// through the list with the up and down arrow keys after a click, returns
170/// [`WatchlistAction::SelectSymbol`]. Column headers cycle through ascending, descending and
171/// list order; sorting keeps symbols inside their section. Clicking a section collapses or
172/// expands its symbols. Unsorted rows are dragged to reorder, and rows are right-clicked to
173/// flag, rename or remove. The host persists [`lists`](Self::lists) and handles file intents.
174pub struct WatchlistPanel {
175    lists: Vec<Watchlist>,
176    active: usize,
177    quotes: HashMap<String, Quote>,
178    /// Decimals of the price columns per symbol, where the host knows them.
179    decimals: HashMap<String, u8>,
180    /// Marks of the symbols, where the host supplies them.
181    marks: HashMap<String, egui::ImageSource<'static>>,
182    /// The columns after the symbol.
183    columns: Vec<WatchlistColumn>,
184    sort: Option<(WatchlistColumn, bool)>,
185    section: WatchlistSection,
186    /// Shows the symbols flagged this way in any list instead of the active list.
187    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    /// Key of the current stream's pushes in the inbox.
194    generation: u64,
195    /// The symbols of the current stream.
196    subscribed: Vec<String>,
197}
198
199impl Default for WatchlistPanel {
200    fn default() -> Self {
201        Self::new(Vec::new())
202    }
203}
204
205impl WatchlistPanel {
206    /// A panel showing the first of `lists`, or one empty list when there are none.
207    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    /// Shows these columns after the symbol, in order; the default is
232    /// [`WatchlistColumn::DEFAULT`]. A quote without a column's figure shows a dash, apart from a
233    /// symbol without a quote, which shows nothing. A sort by a column no longer shown is dropped.
234    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    /// The flag whose symbols the panel shows across every list, if any.
247    pub fn flag_filter(&self) -> Option<WatchlistFlag> {
248        self.flag_filter
249    }
250
251    /// Shows the symbols flagged `flag` in any list, each once, instead of the active list; `None`
252    /// returns to the active list. The view follows flag changes in the lists.
253    pub fn set_flag_filter(&mut self, flag: Option<WatchlistFlag>) {
254        self.flag_filter = flag;
255    }
256
257    /// Every list, for the host to persist.
258    pub fn lists(&self) -> &[Watchlist] {
259        &self.lists
260    }
261
262    /// The symbols the panel shows, which need quotes: those of the active list, or with a
263    /// [flag filter](Self::set_flag_filter) the flagged ones of every list, each once.
264    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    /// Sets the latest quote of `symbol`, ignoring older timestamps and non-finite prices or
279    /// supplied numeric fields. Equal timestamps can correct the current quote.
280    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    /// The latest quote of `symbol`, if the panel holds one.
301    pub fn quote(&self, symbol: &str) -> Option<&Quote> {
302        self.quotes.get(symbol)
303    }
304
305    /// Shows the prices of `symbol` with `decimals`, as `Instrument::price_decimals`. Without
306    /// them, the decimals follow the size of the last price.
307    pub fn set_price_decimals(&mut self, symbol: &str, decimals: u8) {
308        self.decimals.insert(symbol.to_owned(), decimals);
309    }
310
311    /// Shows `mark`, such as the issuer's logo, in a circle before `symbol`. Without one, or while
312    /// it loads or cannot be read, the row shows the symbol's first letter or digit on a neutral disc.
313    pub fn set_mark(&mut self, symbol: &str, mark: egui::ImageSource<'static>) {
314        self.marks.insert(symbol.to_owned(), mark);
315    }
316
317    /// Takes quotes from `service` from the next [`poll`](Self::poll) on.
318    pub fn set_service(&mut self, service: Arc<dyn Quotes>) {
319        self.service = Some(service);
320        self.listener = None;
321    }
322
323    /// Offers copy-link and save-a-copy on the active list, as `sharing` reports them for
324    /// [`ShareKind::Watchlist`]. A saved copy is added to the lists under the name the host
325    /// answers. [`poll`](Self::poll) receives the answers.
326    pub fn set_sharing(&mut self, sharing: Arc<dyn Sharing>) {
327        self.share.set_service(sharing);
328    }
329
330    /// Subscribes again when the symbols to quote changed or the stream lagged, and applies the
331    /// quotes the service pushed. The quotes of symbols no longer subscribed are dropped.
332    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            // Replacing the listener stops the previous stream.
354            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    /// Marks `symbol` selected, such as the symbol the chart shows.
370    pub fn set_selected(&mut self, symbol: &str) {
371        self.selected = Some(symbol.to_owned());
372    }
373
374    /// Appends `symbol` to the active list as [`Watchlist::add_symbol`] does, such as the symbol
375    /// picked after [`WatchlistAction::AddSymbol`]. Returns false when the list is unchanged.
376    pub fn add_symbol(&mut self, symbol: &str) -> bool {
377        self.lists[self.active].add_symbol(symbol)
378    }
379
380    /// Appends `symbol` to the list at `list` in [`lists`](Self::lists) as [`Watchlist::add_symbol`]
381    /// does, such as for [`ContextMenuAction::AddToWatchlist`](crate::ui::context_menu::ContextMenuAction::AddToWatchlist).
382    /// Returns false when there is no such list or it is unchanged.
383    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    /// Adds a list read with [`Watchlist::from_text`] and shows it.
390    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    /// Render the active list and return the host action.
396    pub fn show(&mut self, ui: &mut Ui) -> WatchlistAction {
397        self.show_with(ui, None)
398    }
399
400    /// Render the active list in the wide view: the table with every [`WatchlistColumn::WIDE`]
401    /// column, and tabs for the earnings, dividends and news of `details`, which the host
402    /// fills for the [`symbols`](Self::symbols) shown. The sections list the rows of the
403    /// selected symbol, or of every shown symbol while none of them is selected. Selection,
404    /// sorting, the active list and the flag filter are those of [`show`](Self::show), so
405    /// switching views keeps them. Returns [`WatchlistAction::CompactView`] from its view button.
406    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                // The selected symbol's rows, or those of every shown symbol without one.
585                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        // A flag filter draws copies of the flagged symbols, whose flag changes and removals are
599        // applied to their lists after drawing.
600        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        // Keyed by title, so a section stays collapsed when rows move or the list is sorted.
619        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        // Arrow keys move the selection instead of the keyboard focus.
632        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        // The number columns share 70.5% of the row, 23.5% each by default, which leaves the
669        // symbol column room for a six-letter name after its mark; the last column's own gap
670        // completes the right margin.
671        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                // A painted triangle beside the label: the fonts lack arrow glyphs.
727                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
1066/// Rows of the wide view's section, for the symbols in `focus`; returns the symbol the user
1067/// clicked.
1068fn 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
1172/// Applies the edits of the `filter` flag view to `lists`. Each row stands for its symbol wherever
1173/// it is flagged `filter`, so a new flag reaches every such entry and removing the row removes them
1174/// all; otherwise the row would come back from another list.
1175fn 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
1203/// Where each symbol flagged `flag` first appears: its list and item index, in list order.
1204fn 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
1223/// Item indices in display order: list order, or with `sort`, each run of symbols between
1224/// sections sorted while sections keep their place. Symbols without a value sort last in both
1225/// directions; ties keep list order. The symbols of the `collapsed` sections are left out.
1226fn 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    /// Sorting stays inside each section, puts symbols without a value last in both directions
1314    /// and keeps list order on ties. A collapsed section keeps its title and hides its symbols.
1315    #[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, &quotes, 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    /// A list copy keeps sections, symbol order and flags under a fresh name, the flag view lists a
1372    /// symbol once however many lists flag it and its edits reach each of them, and the new columns
1373    /// read the quote's volume and extended-hours price.
1374    #[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        // A row of the red view stands for the symbol in every list that flags it red, so
1409        // unflagging AAPL or removing XOM there cannot leave a red copy behind in another list.
1410        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        // Hand calculations: 105 - 100 = 5, and 5 / 100 * 100 = 5%.
1439        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(&quote), 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    /// After a click, the arrow keys move through the displayed order and select each symbol.
1520    #[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        // Displayed: B, Section, C, A.
1547        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    /// Guards the quote subscription: the panel subscribes to its list and the selected symbol,
1575    /// applies the pushes of the current stream, and subscribes again with a new stream when its
1576    /// symbols change, stopping the old one (whose late pushes must not land) and dropping the
1577    /// quotes of symbols it no longer quotes. A lagged stream is replaced; unchanged symbols keep
1578    /// the stream.
1579    #[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        // Both service pushes and direct updates retain the latest finite quote.
1609        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    /// Switching between the compact and wide views keeps the selection and the sort, the wide
1662    /// sections list only the selected symbol's rows (every shown symbol's without a selection),
1663    /// and a section that is loading, failed or empty says so.
1664    #[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}