Skip to main content

egui_charts/ui/widget_bar/panels/
hotlists.rs

1//! Hotlists the feed supplies: the market's top movers by the measures the host names.
2
3use crate::localization as l10n;
4use std::sync::Arc;
5
6use egui::{Sense, Ui, vec2};
7
8use super::controls::{cell, dropdown, row};
9use super::{ROW_HEIGHT, signed};
10use super::{format_price, price_decimals};
11use crate::data::inbox::Inbox;
12use crate::data::{Datafeed, FeedError, HotlistEntry, HotlistQuery};
13use crate::icons::icons;
14use crate::theme::ThemeExt;
15use crate::ui_kit::status_message::{MessageKind, status_message, translated_status_message};
16use crate::ui_kit::{EmptyState, LoadingIndicator, PanelHeader};
17
18/// Symbols asked for at most.
19const LIMIT: usize = 50;
20
21/// An action the host performs for the hotlists panel.
22#[derive(Clone, Debug, PartialEq, Default)]
23#[non_exhaustive]
24pub enum HotlistsAction {
25    /// No host action.
26    #[default]
27    None,
28    /// Show this symbol, such as on the chart.
29    SelectSymbol(String),
30}
31
32/// Hotlists fed by the host's [`Datafeed::hotlist`], one at a time.
33///
34/// The host names the lists, such as top gainers, top losers and most active; the user picks
35/// one, each pick asks the feed, and only the answer to the latest request is shown. Call
36/// [`poll`](Self::poll) each frame, then [`show`](Self::show) while the panel is open.
37pub struct HotlistsPanel {
38    lists: Vec<String>,
39    list: usize,
40    feed: Option<Arc<dyn Datafeed>>,
41    entries: Vec<HotlistEntry>,
42    error: Option<FeedError>,
43    loading: bool,
44    inbox: Arc<Inbox>,
45    request: u64,
46}
47
48impl HotlistsPanel {
49    /// A panel of the hotlists named `lists`, showing the first, without a feed.
50    pub fn new(lists: Vec<String>) -> Self {
51        Self {
52            lists,
53            list: 0,
54            feed: None,
55            entries: Vec::new(),
56            error: None,
57            loading: false,
58            inbox: Arc::default(),
59            request: 0,
60        }
61    }
62
63    /// Ask `feed` for the lists from now on, starting at once.
64    pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
65        self.feed = Some(feed);
66        self.refresh();
67    }
68
69    /// Ask the feed again, such as to update the figures.
70    pub fn refresh(&mut self) {
71        let (Some(feed), Some(list)) = (&self.feed, self.lists.get(self.list)) else {
72            return;
73        };
74        self.request += 1;
75        self.loading = true;
76        let query = HotlistQuery {
77            list: list.clone(),
78            limit: LIMIT,
79        };
80        feed.hotlist(query, self.inbox.reply(self.request));
81    }
82
83    /// Receive the feed's answer and wake the UI when the next one arrives.
84    pub fn poll(&mut self, ctx: &egui::Context) {
85        let repaint = ctx.clone();
86        self.inbox.set_waker(move || repaint.request_repaint());
87        for (key, answer) in self.inbox.drain().answers {
88            if key != self.request {
89                continue;
90            }
91            self.loading = false;
92            match *answer
93                .downcast::<Result<Vec<HotlistEntry>, FeedError>>()
94                .expect("hotlist")
95            {
96                Ok(entries) => {
97                    self.entries = entries;
98                    self.error = None;
99                }
100                Err(error) => {
101                    self.entries.clear();
102                    self.error = Some(error);
103                }
104            }
105        }
106    }
107
108    /// Render the chosen list after [`poll`](Self::poll).
109    pub fn show(&mut self, ui: &mut Ui) -> HotlistsAction {
110        let mut action = HotlistsAction::None;
111        let theme = ui.active_theme();
112        PanelHeader::new("Hotlists").show_plain(ui);
113        let before = self.list;
114        if let Some(name) = self.lists.get(self.list) {
115            dropdown(ui, "hotlist", name, |ui| {
116                for (index, name) in self.lists.iter().enumerate() {
117                    ui.selectable_value(&mut self.list, index, l10n::text(ui.ctx(), name));
118                }
119            });
120        }
121        if self.list != before {
122            self.refresh();
123        }
124        match &self.error {
125            Some(FeedError::NotSupported) => {
126                status_message(ui, MessageKind::Info, "This feed supplies no hotlists.");
127            }
128            Some(error) => {
129                translated_status_message(
130                    ui,
131                    MessageKind::Error,
132                    l10n::format_text(
133                        ui.ctx(),
134                        "Hotlist unavailable: {error}",
135                        &[("error", error.to_string().as_str())],
136                    ),
137                );
138            }
139            None if self.loading && self.entries.is_empty() => {
140                LoadingIndicator::new().message("Loading hotlist").show(ui);
141            }
142            None if self.entries.is_empty() => {
143                EmptyState::new("No symbols in this list")
144                    .icon(&icons::WIDGET_BAR_HOTLISTS)
145                    .fill_height()
146                    .show(ui);
147            }
148            None => {}
149        }
150        egui::ScrollArea::vertical()
151            .auto_shrink([false, false])
152            .show(ui, |ui| {
153                ui.spacing_mut().item_spacing.y = 0.0;
154                let columns = |rect: egui::Rect| {
155                    let number = ((rect.width() - 18.0).max(0.0) * 0.3).min(96.0);
156                    let right = rect.right() - 8.0;
157                    let edges = [
158                        rect.left() + 10.0,
159                        right - 2.0 * number,
160                        right - number,
161                        right,
162                    ];
163                    std::array::from_fn::<_, 3, _>(|i| {
164                        egui::Rect::from_min_max(
165                            egui::pos2(edges[i], rect.top()),
166                            egui::pos2((edges[i + 1] - 4.0).max(edges[i]), rect.bottom()),
167                        )
168                    })
169                };
170                let (header, _) =
171                    ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover());
172                for (index, title) in ["Symbol", "Last", "Chg%"].iter().enumerate() {
173                    cell(
174                        ui,
175                        columns(header)[index],
176                        title,
177                        index != 0,
178                        theme.ui.text_secondary,
179                    );
180                }
181                for entry in &self.entries {
182                    let color = if entry.change_percent > 0.0 {
183                        theme.chart.candle_up
184                    } else if entry.change_percent < 0.0 {
185                        theme.chart.candle_down
186                    } else {
187                        theme.ui.text
188                    };
189                    let (rect, response) = row(ui);
190                    let cells = columns(rect);
191                    cell(ui, cells[0], &entry.symbol, false, theme.ui.text);
192                    cell(
193                        ui,
194                        cells[1],
195                        &format_price(
196                            entry.last,
197                            Some(
198                                entry
199                                    .price_decimals
200                                    .unwrap_or_else(|| price_decimals(entry.last)),
201                            ),
202                        ),
203                        true,
204                        theme.ui.text,
205                    );
206                    cell(
207                        ui,
208                        cells[2],
209                        &format!("{}%", signed(entry.change_percent, 2)),
210                        true,
211                        color,
212                    );
213                    let translated_label = l10n::text(ui.ctx(), &entry.symbol);
214                    response.widget_info(|| {
215                        egui::WidgetInfo::labeled(egui::Role::Button, true, &translated_label)
216                    });
217                    if response.clicked() {
218                        action = HotlistsAction::SelectSymbol(entry.symbol.clone());
219                    }
220                }
221            });
222        action
223    }
224}