Skip to main content

egui_charts/ui/widget_bar/panels/
news.rs

1//! Headlines and highlights the feed supplies, for the chart's symbol, more symbols or the whole
2//! market, narrowed by topic and provider.
3
4use 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
20/// Stories asked for at most.
21const LIMIT: usize = 50;
22
23/// An action the host performs for the news panel.
24#[derive(Clone, Debug, PartialEq, Default)]
25#[non_exhaustive]
26pub enum NewsAction {
27    /// No host action.
28    #[default]
29    None,
30    /// Show the chart at the time a story was published, such as with `Chart::scroll_to_time`.
31    ShowOnChart(DateTime<Utc>),
32    /// Open the full story at this link.
33    OpenUrl(String),
34}
35
36/// News or highlights fed by the host's [`Datafeed::news`].
37///
38/// Keep it on the chart's symbol with [`set_symbol`](Self::set_symbol); the user switches between
39/// that symbol's stories and the whole market's. With [`set_symbols`](Self::set_symbols) they can
40/// add symbols, and with [`set_topics`](Self::set_topics) and
41/// [`set_providers`](Self::set_providers) narrow the stories by topic and provider; offering the
42/// values again keeps the choices still offered. Each change asks the feed again with the
43/// complete selection, and only the answer to the latest request is shown. A story opens to its text, with actions to show the chart at its time and to open its
44/// link. Call [`poll`](Self::poll) each frame, then
45/// [`show`](Self::show) while the panel is open.
46pub 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    /// A headlines panel without a feed.
70    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    /// A highlights panel without a feed: summaries shown under their headlines.
89    pub fn highlights() -> Self {
90        Self {
91            highlights: true,
92            ..Self::new()
93        }
94    }
95
96    /// Ask `feed` for stories from now on, starting at once.
97    pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
98        self.feed = Some(feed);
99        self.refresh();
100    }
101
102    /// Follow `symbol`, such as the chart's. A new symbol asks again while its stories are shown.
103    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    /// Offer `symbols` as further symbols to show stories about, next to the followed one. None is
113    /// selected at first; choices still offered stay selected.
114    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    /// Offer `topics`, in the values the feed supports, as a filter. None is selected at first,
121    /// which lists every topic; choices still offered stay selected.
122    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    /// Offer `providers`, in the identifiers the feed uses, as a filter. None is selected at
129    /// first, which lists every provider; choices still offered stay selected.
130    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    /// Ask the feed again, such as when the host learns of new stories.
137    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    /// Receive the feed's answer and wake the UI when the next one arrives.
163    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    /// Render the stories, or the open story, after [`poll`](Self::poll).
196    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
433/// Replace the offered values with `values`, keeping the choice of those still offered, and
434/// tell whether the choice changed.
435fn 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
447/// The values the user ticked.
448fn chosen(choices: &[(String, bool)]) -> impl Iterator<Item = &String> {
449    choices.iter().filter(|(_, on)| *on).map(|(value, _)| value)
450}
451
452/// The publication time in UTC and the source.
453fn 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    /// Guards which stories show: a new symbol asks for its stories, the market scope asks
481    /// without a symbol and ignores later symbol changes, and an answer to an earlier request
482    /// never replaces the latest one, which an answer arriving late would otherwise do.
483    #[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        // The complete selection is sent. Offering values again keeps the ones still offered and
516        // asks again only when that changes the selection.
517        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}