1use crate::localization as l10n;
5use std::sync::Arc;
6
7use chrono::{DateTime, Utc};
8use egui::{Label, RichText, Sense, Ui};
9
10use crate::data::inbox::Inbox;
11use crate::data::{Datafeed, FeedError, NewsQuery, NewsStory};
12use crate::icons::icons;
13use crate::theme::ThemeExt;
14use crate::ui_kit::buttons::Button;
15use crate::ui_kit::status_message::{MessageKind, status_message, translated_status_message};
16use crate::ui_kit::{EmptyState, IconButton, LoadingIndicator, PanelHeader};
17
18use super::controls::dropdown;
19
20const LIMIT: usize = 50;
22
23#[derive(Clone, Debug, PartialEq, Default)]
25#[non_exhaustive]
26pub enum NewsAction {
27 #[default]
29 None,
30 ShowOnChart(DateTime<Utc>),
32 OpenUrl(String),
34}
35
36pub struct NewsPanel {
47 highlights: bool,
48 feed: Option<Arc<dyn Datafeed>>,
49 symbol: Option<String>,
50 market: bool,
51 more_symbols: Vec<(String, bool)>,
52 topics: Vec<(String, bool)>,
53 providers: Vec<(String, bool)>,
54 stories: Vec<NewsStory>,
55 open: Option<String>,
56 error: Option<FeedError>,
57 loading: bool,
58 inbox: Arc<Inbox>,
59 request: u64,
60}
61
62impl Default for NewsPanel {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl NewsPanel {
69 pub fn new() -> Self {
71 Self {
72 highlights: false,
73 feed: None,
74 symbol: None,
75 market: false,
76 more_symbols: Vec::new(),
77 topics: Vec::new(),
78 providers: Vec::new(),
79 stories: Vec::new(),
80 open: None,
81 error: None,
82 loading: false,
83 inbox: Arc::default(),
84 request: 0,
85 }
86 }
87
88 pub fn highlights() -> Self {
90 Self {
91 highlights: true,
92 ..Self::new()
93 }
94 }
95
96 pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
98 self.feed = Some(feed);
99 self.refresh();
100 }
101
102 pub fn set_symbol(&mut self, symbol: &str) {
104 if self.symbol.as_deref() != Some(symbol) {
105 self.symbol = Some(symbol.to_owned());
106 if !self.market {
107 self.refresh();
108 }
109 }
110 }
111
112 pub fn set_symbols(&mut self, symbols: impl IntoIterator<Item = String>) {
115 if offer(&mut self.more_symbols, symbols) {
116 self.refresh();
117 }
118 }
119
120 pub fn set_topics(&mut self, topics: impl IntoIterator<Item = String>) {
123 if offer(&mut self.topics, topics) {
124 self.refresh();
125 }
126 }
127
128 pub fn set_providers(&mut self, providers: impl IntoIterator<Item = String>) {
131 if offer(&mut self.providers, providers) {
132 self.refresh();
133 }
134 }
135
136 pub fn refresh(&mut self) {
138 let Some(feed) = &self.feed else {
139 return;
140 };
141 self.request += 1;
142 self.loading = true;
143 let symbols = if self.market {
144 Vec::new()
145 } else {
146 self.symbol
147 .iter()
148 .chain(chosen(&self.more_symbols))
149 .cloned()
150 .collect()
151 };
152 let query = NewsQuery {
153 symbols,
154 topics: chosen(&self.topics).cloned().collect(),
155 providers: chosen(&self.providers).cloned().collect(),
156 highlights: self.highlights,
157 limit: LIMIT,
158 };
159 feed.news(query, self.inbox.reply(self.request));
160 }
161
162 pub fn poll(&mut self, ctx: &egui::Context) {
164 let repaint = ctx.clone();
165 self.inbox.set_waker(move || repaint.request_repaint());
166 for (key, answer) in self.inbox.drain().answers {
167 if key != self.request {
168 continue;
169 }
170 self.loading = false;
171 match *answer
172 .downcast::<Result<Vec<NewsStory>, FeedError>>()
173 .expect("news")
174 {
175 Ok(stories) => {
176 self.stories = stories;
177 self.error = None;
178 if self
179 .open
180 .as_ref()
181 .is_some_and(|id| !self.stories.iter().any(|story| story.id == *id))
182 {
183 self.open = None;
184 }
185 }
186 Err(error) => {
187 self.stories.clear();
188 self.open = None;
189 self.error = Some(error);
190 }
191 }
192 }
193 }
194
195 pub fn show(&mut self, ui: &mut Ui) -> NewsAction {
197 let mut action = NewsAction::None;
198 let theme = ui.active_theme();
199 let title = if self.highlights {
200 "Highlights"
201 } else {
202 "News"
203 };
204 if let Some(story) = self
205 .open
206 .as_ref()
207 .and_then(|id| self.stories.iter().find(|story| story.id == *id))
208 {
209 let mut back = false;
210 PanelHeader::new(title).show(ui, |ui| {
211 back = IconButton::new(&icons::CHEVRON_LEFT)
212 .tooltip("Back to the stories")
213 .show(ui)
214 .clicked();
215 });
216 egui::ScrollArea::vertical()
217 .auto_shrink([false, false])
218 .show(ui, |ui| {
219 ui.label(
220 RichText::new(l10n::text(ui.ctx(), &story.headline))
221 .size(theme.text.heading)
222 .strong(),
223 );
224 ui.label(
225 RichText::new(l10n::text(ui.ctx(), meta(story)))
226 .size(theme.text.small)
227 .color(theme.ui.text_secondary),
228 );
229 if !story.symbols.is_empty() {
230 ui.label(
231 RichText::new(l10n::text(ui.ctx(), story.symbols.join(", ")))
232 .size(theme.text.small)
233 .color(theme.ui.text_secondary),
234 );
235 }
236 ui.add_space(theme.spacing.sm);
237 if !story.body.is_empty() {
238 ui.label(l10n::text(ui.ctx(), &story.body));
239 ui.add_space(theme.spacing.sm);
240 }
241 ui.horizontal_wrapped(|ui| {
242 if Button::new("Show on chart").show(ui).clicked() {
243 action = NewsAction::ShowOnChart(story.time);
244 }
245 if let Some(url) = &story.url
246 && Button::new("Open story").show(ui).clicked()
247 {
248 action = NewsAction::OpenUrl(url.clone());
249 }
250 });
251 });
252 if back {
253 self.open = None;
254 }
255 return action;
256 }
257 let mut refresh = false;
258 PanelHeader::new(title).show(ui, |ui| {
259 refresh = IconButton::new(&icons::ROTATE_CCW)
260 .tooltip("Refresh")
261 .enabled(self.feed.is_some())
262 .show(ui)
263 .clicked();
264 });
265 let market = self.market;
266 ui.allocate_ui_with_layout(
267 egui::vec2(ui.available_width(), 24.0),
268 egui::Layout::right_to_left(egui::Align::Center),
269 |ui| {
270 if Button::new("Market")
271 .small()
272 .selected(self.market)
273 .show(ui)
274 .clicked()
275 {
276 self.market = true;
277 }
278 ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
279 if let Some(symbol) = &self.symbol
280 && ui
281 .add(
282 egui::Button::new(l10n::text(ui.ctx(), symbol))
283 .frame_when_inactive(!self.market)
284 .stroke(egui::Stroke::NONE)
285 .selected(!self.market)
286 .min_size(egui::vec2(0.0, 24.0))
287 .truncate(),
288 )
289 .on_hover_text(l10n::text(ui.ctx(), symbol))
290 .clicked()
291 {
292 self.market = false;
293 }
294 });
295 },
296 );
297 let before = (
298 self.more_symbols.clone(),
299 self.topics.clone(),
300 self.providers.clone(),
301 );
302 let offered = !(self.topics.is_empty() && self.providers.is_empty())
303 || (!self.more_symbols.is_empty() && !self.market);
304 if offered {
305 ui.horizontal_wrapped(|ui| {
306 for (id, all, some, choices) in [
307 (
308 "news_symbols",
309 "All symbols",
310 "{count} symbols",
311 &mut self.more_symbols,
312 ),
313 (
314 "news_topics",
315 "All topics",
316 "{count} topics",
317 &mut self.topics,
318 ),
319 (
320 "news_providers",
321 "All providers",
322 "{count} providers",
323 &mut self.providers,
324 ),
325 ] {
326 if choices.is_empty() || (id == "news_symbols" && self.market) {
327 continue;
328 }
329 let count = choices.iter().filter(|(_, on)| *on).count();
330 let text = if count == 0 {
331 l10n::text(ui.ctx(), all)
332 } else {
333 l10n::format_text(ui.ctx(), some, &[("count", count.to_string().as_str())])
334 };
335 dropdown(ui, id, &text, |ui| {
336 for (value, on) in choices.iter_mut() {
337 ui.checkbox(on, l10n::text(ui.ctx(), value.as_str()));
338 }
339 });
340 }
341 });
342 }
343 if refresh
344 || market != self.market
345 || before
346 != (
347 self.more_symbols.clone(),
348 self.topics.clone(),
349 self.providers.clone(),
350 )
351 {
352 self.refresh();
353 }
354 match &self.error {
355 Some(FeedError::NotSupported) => {
356 status_message(ui, MessageKind::Info, "This feed supplies no stories.");
357 }
358 Some(error) => {
359 translated_status_message(
360 ui,
361 MessageKind::Error,
362 l10n::format_text(
363 ui.ctx(),
364 "Stories unavailable: {error}",
365 &[("error", error.to_string().as_str())],
366 ),
367 );
368 }
369 None if self.loading && self.stories.is_empty() => {
370 LoadingIndicator::new().message("Loading stories").show(ui);
371 }
372 None if self.stories.is_empty() => {
373 EmptyState::new("No stories")
374 .icon(&icons::WIDGET_BAR_NEWS)
375 .fill_height()
376 .show(ui);
377 }
378 None => {}
379 }
380 egui::ScrollArea::vertical()
381 .auto_shrink([false, false])
382 .show(ui, |ui| {
383 for story in &self.stories {
384 let response = ui
385 .scope_builder(egui::UiBuilder::new().sense(Sense::click()), |ui| {
386 let hovered = ui.response().hovered();
387 egui::Frame::new()
388 .fill(if hovered {
389 theme.ui.btn_bg_hover
390 } else {
391 egui::Color32::TRANSPARENT
392 })
393 .inner_margin(egui::Margin::symmetric(8, 0))
394 .show(ui, |ui| {
395 ui.set_width(ui.available_width());
396 ui.set_min_height(26.0);
397 ui.style_mut().interaction.selectable_labels = false;
398 ui.label(
399 RichText::new(l10n::text(ui.ctx(), meta(story)))
400 .size(theme.text.small)
401 .color(theme.ui.text_secondary),
402 );
403 ui.add(
404 Label::new(
405 RichText::new(l10n::text(ui.ctx(), &story.headline))
406 .strong(),
407 )
408 .wrap(),
409 );
410 if self.highlights && !story.body.is_empty() {
411 ui.label(l10n::text(ui.ctx(), &story.body));
412 }
413 });
414 })
415 .response;
416 let translated_label = l10n::text(ui.ctx(), &story.headline);
417 response.widget_info(|| {
418 egui::WidgetInfo::labeled(egui::Role::Button, true, &translated_label)
419 });
420 if response
421 .on_hover_cursor(egui::CursorIcon::PointingHand)
422 .clicked()
423 {
424 self.open = Some(story.id.clone());
425 }
426 ui.separator();
427 }
428 });
429 action
430 }
431}
432
433fn offer(choices: &mut Vec<(String, bool)>, values: impl IntoIterator<Item = String>) -> bool {
436 let kept = std::mem::take(choices);
437 *choices = values
438 .into_iter()
439 .map(|value| {
440 let on = kept.iter().any(|(old, on)| *on && *old == value);
441 (value, on)
442 })
443 .collect();
444 !chosen(&kept).eq(chosen(choices))
445}
446
447fn chosen(choices: &[(String, bool)]) -> impl Iterator<Item = &String> {
449 choices.iter().filter(|(_, on)| *on).map(|(value, _)| value)
450}
451
452fn meta(story: &NewsStory) -> String {
454 let time = story.time.format("%b %-d, %H:%M UTC").to_string();
455 if story.source.is_empty() {
456 time
457 } else {
458 format!("{time} ยท {}", story.source)
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::data::{HistoryPage, HistoryRequest, Reply};
466 use std::sync::Mutex;
467
468 #[derive(Default)]
469 struct Feed(Mutex<Vec<(NewsQuery, Reply<Vec<NewsStory>>)>>);
470
471 impl Datafeed for Feed {
472 fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
473 reply.send(Err(FeedError::NotSupported));
474 }
475 fn news(&self, query: NewsQuery, reply: Reply<Vec<NewsStory>>) {
476 self.0.lock().unwrap().push((query, reply));
477 }
478 }
479
480 #[test]
484 fn only_the_latest_request_answers_and_the_scope_follows_the_symbol() {
485 let feed = Arc::new(Feed::default());
486 let mut panel = NewsPanel::new();
487 panel.set_symbol("AAA");
488 panel.set_feed(feed.clone());
489 panel.set_symbol("BBB");
490 let ctx = egui::Context::default();
491 let story = |id: &str| NewsStory::new(id, DateTime::UNIX_EPOCH, id);
492 let mut requests = std::mem::take(&mut *feed.0.lock().unwrap());
493 let queried: Vec<_> = requests.iter().map(|(q, _)| q.symbols.clone()).collect();
494 assert_eq!(queried, [["AAA"], ["BBB"]]);
495 let (_, latest) = requests.pop().unwrap();
496 let (_, earlier) = requests.pop().unwrap();
497 latest.send(Ok(vec![story("b")]));
498 earlier.send(Ok(vec![story("a")]));
499 panel.poll(&ctx);
500 assert_eq!(panel.stories, [story("b")]);
501
502 panel.set_topics(["tech".into(), "fx".into()]);
503 panel.set_providers(["wire".into()]);
504 panel.set_symbols(["DDD".into()]);
505 panel.topics[1].1 = true;
506 panel.more_symbols[0].1 = true;
507 panel.providers[0].1 = true;
508 panel.refresh();
509 panel.set_topics(["fx".into(), "oil".into()]);
510 panel.set_providers(["other".into()]);
511 let queries: Vec<_> = std::mem::take(&mut *feed.0.lock().unwrap())
512 .into_iter()
513 .map(|(query, _)| (query.symbols, query.topics, query.providers))
514 .collect();
515 let symbols = vec!["BBB".to_owned(), "DDD".to_owned()];
518 let fx = vec!["fx".to_owned()];
519 assert_eq!(
520 queries,
521 [
522 (symbols.clone(), fx.clone(), vec!["wire".to_owned()]),
523 (symbols, fx, vec![]),
524 ]
525 );
526
527 panel.market = true;
528 panel.refresh();
529 panel.set_symbol("CCC");
530 let mut requests = std::mem::take(&mut *feed.0.lock().unwrap());
531 assert_eq!(requests.len(), 1);
532 let (query, reply) = requests.pop().unwrap();
533 assert_eq!((query.symbols, query.highlights), (vec![], false));
534 reply.send(Err(FeedError::NotSupported));
535 panel.poll(&ctx);
536 assert!(panel.stories.is_empty());
537 assert_eq!(panel.error, Some(FeedError::NotSupported));
538 }
539}