egui_charts/ui/widget_bar/panels/
hotlists.rs1use 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
18const LIMIT: usize = 50;
20
21#[derive(Clone, Debug, PartialEq, Default)]
23#[non_exhaustive]
24pub enum HotlistsAction {
25 #[default]
27 None,
28 SelectSymbol(String),
30}
31
32pub 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 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 pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
65 self.feed = Some(feed);
66 self.refresh();
67 }
68
69 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 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 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}