Skip to main content

egui_charts/ui/widget_bar/panels/
screener.rs

1//! A screener table of the rows the feed finds for the user's filters, universe and description.
2
3use crate::localization as l10n;
4use std::sync::Arc;
5
6use egui::{Sense, Ui, vec2};
7use serde::{Deserialize, Serialize};
8
9use super::ROW_HEIGHT;
10use super::controls::{cell, dropdown, row};
11use crate::data::inbox::Inbox;
12use crate::data::{
13    Datafeed, FeedError, ScreenerColumnSet, ScreenerFilter, ScreenerOp, ScreenerQuery, ScreenerRow,
14    ScreenerTextAnswer, ScreenerUniverse,
15};
16use crate::icons::icons;
17use crate::services::{Storage, StoreKind};
18use crate::theme::ThemeExt;
19use crate::ui::dialogs::templates::TemplateStore;
20use crate::ui_kit::buttons::Button;
21use crate::ui_kit::filter::FilterWidget;
22use crate::ui_kit::status_message::{MessageKind, status_message, translated_status_message};
23use crate::ui_kit::{EmptyState, LoadingIndicator, PanelHeader};
24
25/// Rows asked for at most.
26const LIMIT: usize = 200;
27
28/// An action the host performs for the screener.
29#[derive(Clone, Debug, PartialEq, Default)]
30#[non_exhaustive]
31pub enum ScreenerAction {
32    /// No host action.
33    #[default]
34    None,
35    /// Show this symbol, such as on the chart.
36    SelectSymbol(String),
37    /// Save this CSV text of the shown rows to a file the host chooses.
38    ExportCsv(String),
39    /// Add these symbols, the shown rows in order, to a watchlist, such as with
40    /// [`WatchlistPanel::import`](super::WatchlistPanel::import).
41    SendToWatchlist(Vec<String>),
42}
43
44/// A screen the user named, as kept in [`StoreKind::Screens`].
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46struct SavedScreen {
47    universe: Option<String>,
48    /// Name of the column set.
49    set: String,
50    filters: Vec<ScreenerFilter>,
51    sort_by: Option<String>,
52    descending: bool,
53    text: String,
54}
55
56/// A screener fed by the host's [`Datafeed::screen`].
57///
58/// The host gives the column sets; the user picks one, adds filters on its columns and sorts by
59/// a column by clicking its heading. With [`set_universes`](Self::set_universes) the user also
60/// picks the market to screen, and with [`set_text_query`](Self::set_text_query) describes the
61/// screen in words, which the feed interprets in [`Datafeed::screen_text`] and explains above the
62/// rows. [`set_storage`](Self::set_storage) lets the user save, rename, load and delete named
63/// screens. Each change asks the feed again, which filters, sorts and limits, and only the answer
64/// to the latest request is shown. A full answer offers the next page of the same screen. The
65/// chosen criteria stay through loading, failures, paging and export. Call [`poll`](Self::poll)
66/// each frame, then [`show`](Self::show) while the panel is open.
67pub struct ScreenerPanel {
68    sets: Vec<ScreenerColumnSet>,
69    set: usize,
70    universes: Vec<ScreenerUniverse>,
71    universe: Option<String>,
72    text_query: bool,
73    /// The description being written, and the one the rows answer.
74    description: FilterWidget,
75    text: String,
76    explanation: String,
77    saved: TemplateStore<SavedScreen>,
78    stored: bool,
79    /// The saved screen last saved or loaded, and the name being written.
80    current: Option<String>,
81    name: FilterWidget,
82    /// The name a renamed screen had, deleted once the save under its new name succeeds.
83    renaming: Option<String>,
84    more: bool,
85    appending: bool,
86    filters: Vec<ScreenerFilter>,
87    sort_by: Option<String>,
88    descending: bool,
89    /// The filter being written: its column within the set, comparison and value text.
90    draft: (usize, ScreenerOp, FilterWidget),
91    feed: Option<Arc<dyn Datafeed>>,
92    /// The latest answer and the set it was asked with.
93    rows: Vec<ScreenerRow>,
94    answered: usize,
95    error: Option<FeedError>,
96    loading: bool,
97    inbox: Arc<Inbox>,
98    request: u64,
99}
100
101impl ScreenerPanel {
102    /// A screener of `sets`, showing the first, without a feed.
103    pub fn new(sets: Vec<ScreenerColumnSet>) -> Self {
104        Self {
105            sets,
106            set: 0,
107            universes: Vec::new(),
108            universe: None,
109            text_query: false,
110            description: FilterWidget::new().placeholder("Describe a screen"),
111            text: String::new(),
112            explanation: String::new(),
113            saved: TemplateStore::new(StoreKind::Screens),
114            stored: false,
115            current: None,
116            name: FilterWidget::new().placeholder("Screen name"),
117            renaming: None,
118            more: false,
119            appending: false,
120            filters: Vec::new(),
121            sort_by: None,
122            descending: true,
123            draft: (
124                0,
125                ScreenerOp::Above,
126                FilterWidget::new().placeholder("Value"),
127            ),
128            feed: None,
129            rows: Vec::new(),
130            answered: 0,
131            error: None,
132            loading: false,
133            inbox: Arc::default(),
134            request: 0,
135        }
136    }
137
138    /// Ask `feed` for rows from now on, starting at once.
139    pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
140        self.feed = Some(feed);
141        self.refresh();
142    }
143
144    /// Offer `universes` as the markets to screen, the first at first. The chosen one stays chosen
145    /// while it is still offered.
146    pub fn set_universes(&mut self, universes: Vec<ScreenerUniverse>) {
147        let before = self.universe.clone();
148        if !universes
149            .iter()
150            .any(|u| Some(&u.id) == self.universe.as_ref())
151        {
152            self.universe = universes.first().map(|u| u.id.clone());
153        }
154        self.universes = universes;
155        if self.universe != before {
156            self.refresh();
157        }
158    }
159
160    /// Offer a field to describe the screen in words, which the feed interprets in
161    /// [`Datafeed::screen_text`].
162    pub fn set_text_query(&mut self, enabled: bool) {
163        self.text_query = enabled;
164    }
165
166    /// Keep named screens in `storage`, in [`StoreKind::Screens`]. Poll every frame, as the panel
167    /// is.
168    pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
169        self.stored = true;
170        self.saved.set_storage(storage);
171    }
172
173    fn query(&self, offset: usize) -> ScreenerQuery {
174        ScreenerQuery {
175            universe: self.universe.clone(),
176            text: self.text.clone(),
177            offset,
178            columns: self.sets.get(self.set).map_or_else(Vec::new, |set| {
179                set.columns.iter().map(|column| column.id.clone()).collect()
180            }),
181            filters: self.filters.clone(),
182            sort_by: self.sort_by.clone(),
183            descending: self.descending,
184            limit: LIMIT,
185        }
186    }
187
188    /// Ask the feed again, such as when the host's data changed.
189    pub fn refresh(&mut self) {
190        self.ask(0);
191    }
192
193    /// Ask for the page after the rows shown, of the same screen.
194    fn load_more(&mut self) {
195        self.ask(self.rows.len());
196    }
197
198    fn ask(&mut self, offset: usize) {
199        let Some(feed) = &self.feed else {
200            return;
201        };
202        self.request += 1;
203        self.loading = true;
204        self.appending = offset > 0;
205        let query = self.query(offset);
206        if self.text.is_empty() {
207            feed.screen(query, self.inbox.reply(self.request));
208        } else {
209            feed.screen_text(query, self.inbox.reply(self.request));
210        }
211    }
212
213    fn saved_screen(&self) -> SavedScreen {
214        SavedScreen {
215            universe: self.universe.clone(),
216            set: self
217                .sets
218                .get(self.set)
219                .map_or_else(String::new, |set| set.name.clone()),
220            filters: self.filters.clone(),
221            sort_by: self.sort_by.clone(),
222            descending: self.descending,
223            text: self.text.clone(),
224        }
225    }
226
227    /// Make the criteria of `saved` the panel's and ask the feed.
228    fn load_screen(&mut self, ctx: &egui::Context, name: &str) {
229        let Some(saved) = self.saved.entries().get(name).cloned() else {
230            return;
231        };
232        let Some(set) = self.sets.iter().position(|set| set.name == saved.set) else {
233            self.saved.error = Some(l10n::format_text(
234                ctx,
235                "The column set {name} is not available",
236                &[("name", saved.set.as_str())],
237            ));
238            return;
239        };
240        self.set = set;
241        self.draft.0 = 0;
242        self.universe = saved.universe;
243        self.filters = saved.filters;
244        self.sort_by = saved.sort_by;
245        self.descending = saved.descending;
246        *self.description.query_mut() = saved.text.clone();
247        self.text = saved.text;
248        *self.name.query_mut() = name.to_owned();
249        self.current = Some(name.to_owned());
250        self.saved.error = None;
251        self.refresh();
252    }
253
254    /// Receive the feed's answer and wake the UI when the next one arrives.
255    pub fn poll(&mut self, ctx: &egui::Context) {
256        let repaint = ctx.clone();
257        self.inbox.set_waker(move || repaint.request_repaint());
258        self.saved.poll(ctx);
259        if self.saved.ready()
260            && let Some(old) = self.renaming.take()
261            && self.saved.error.is_none()
262        {
263            let _ = self.saved.delete(&old);
264        }
265        for (key, answer) in self.inbox.drain().answers {
266            if key != self.request {
267                continue;
268            }
269            self.loading = false;
270            let answer = match answer.downcast::<Result<Vec<ScreenerRow>, FeedError>>() {
271                Ok(rows) => (*rows).map(|rows| ScreenerTextAnswer::new(rows, "")),
272                Err(answer) => *answer
273                    .downcast::<Result<ScreenerTextAnswer, FeedError>>()
274                    .expect("screener rows"),
275            };
276            match answer {
277                Ok(answer) => {
278                    self.more = answer.rows.len() >= LIMIT;
279                    if self.appending {
280                        self.rows.extend(answer.rows);
281                    } else {
282                        self.rows = answer.rows;
283                        self.explanation = answer.explanation;
284                    }
285                    self.answered = self.set;
286                    self.error = None;
287                }
288                Err(error) => {
289                    // A failed next page leaves the rows already shown.
290                    if !self.appending {
291                        self.rows.clear();
292                        self.explanation.clear();
293                        self.more = false;
294                    }
295                    self.error = Some(error);
296                }
297            }
298            self.appending = false;
299        }
300    }
301
302    /// The shown rows as CSV, headed by Symbol and the column titles. Every field is quoted and
303    /// embedded quotes doubled.
304    fn csv(&self) -> String {
305        let field = |text: &str| format!("\"{}\"", text.replace('"', "\"\""));
306        let titles = self
307            .sets
308            .get(self.answered)
309            .map_or(&[][..], |set| &set.columns);
310        let mut csv = std::iter::once(field("Symbol"))
311            .chain(titles.iter().map(|column| field(&column.title)))
312            .collect::<Vec<_>>()
313            .join(",");
314        csv.push_str("\r\n");
315        for row in &self.rows {
316            let line = std::iter::once(&row.symbol)
317                .chain(&row.values)
318                .map(|text| field(text))
319                .collect::<Vec<_>>()
320                .join(",");
321            csv.push_str(&line);
322            csv.push_str("\r\n");
323        }
324        csv
325    }
326
327    /// Render the filters and the table after [`poll`](Self::poll).
328    pub fn show(&mut self, ui: &mut Ui) -> ScreenerAction {
329        let mut action = ScreenerAction::None;
330        let theme = ui.active_theme();
331        let has_rows = !self.rows.is_empty();
332        PanelHeader::new("Screener").show_plain(ui);
333        let mut changed = false;
334        let Some(set) = self.sets.get(self.set) else {
335            EmptyState::new("No columns to screen")
336                .icon(&icons::WIDGET_BAR_SCREENER)
337                .fill_height()
338                .show(ui);
339            return action;
340        };
341        let (name, columns) = (set.name.clone(), set.columns.clone());
342        let title = |id: &str| {
343            columns
344                .iter()
345                .find(|column| column.id == id)
346                .map_or(id.to_owned(), |column| column.title.clone())
347        };
348        ui.horizontal_wrapped(|ui| {
349            let before = self.set;
350            dropdown(ui, "screener_set", &name, |ui| {
351                for (index, set) in self.sets.iter().enumerate() {
352                    ui.selectable_value(&mut self.set, index, l10n::text(ui.ctx(), &set.name));
353                }
354            });
355            if self.set != before {
356                changed = true;
357                self.draft.0 = 0;
358            }
359            if !self.universes.is_empty() {
360                let before = self.universe.clone();
361                let shown = self
362                    .universes
363                    .iter()
364                    .find(|u| Some(&u.id) == self.universe.as_ref())
365                    .map_or("Market", |u| u.name.as_str())
366                    .to_owned();
367                dropdown(ui, "screener_universe", &shown, |ui| {
368                    for universe in &self.universes {
369                        ui.selectable_value(
370                            &mut self.universe,
371                            Some(universe.id.clone()),
372                            l10n::text(ui.ctx(), &universe.name),
373                        );
374                    }
375                });
376                changed |= self.universe != before;
377            }
378            if Button::new("Send to watchlist")
379                .enabled(has_rows)
380                .show(ui)
381                .clicked()
382            {
383                action = ScreenerAction::SendToWatchlist(
384                    self.rows.iter().map(|row| row.symbol.clone()).collect(),
385                );
386            }
387            if Button::new("Export CSV…")
388                .enabled(has_rows)
389                .show(ui)
390                .clicked()
391            {
392                action = ScreenerAction::ExportCsv(self.csv());
393            }
394        });
395        if self.text_query {
396            ui.horizontal_wrapped(|ui| {
397                self.description = std::mem::take(&mut self.description)
398                    .placeholder("Describe a screen")
399                    .show_icon(false);
400                let response = ui
401                    .allocate_ui(vec2(160.0, 28.0), |ui| self.description.show(ui).response)
402                    .inner;
403                let written = self.description.query().trim().to_owned();
404                let entered =
405                    response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
406                if (entered
407                    || Button::new("Ask")
408                        .enabled(written != self.text)
409                        .show(ui)
410                        .clicked())
411                    && written != self.text
412                {
413                    self.text = written;
414                    changed = true;
415                }
416            });
417            if !self.text.is_empty() && !self.explanation.is_empty() {
418                ui.label(l10n::text(ui.ctx(), &self.explanation));
419            }
420        }
421        if self.stored {
422            let mut load = None;
423            let (mut save, mut rename, mut delete) = (false, false, false);
424            ui.horizontal_wrapped(|ui| {
425                if !self.saved.entries().is_empty() {
426                    dropdown(
427                        ui,
428                        "screener_saved",
429                        self.current.as_deref().unwrap_or("Saved screens"),
430                        |ui| {
431                            for name in self.saved.entries().keys() {
432                                let on = self.current.as_deref() == Some(name.as_str());
433                                if ui
434                                    .selectable_label(on, l10n::text(ui.ctx(), name))
435                                    .clicked()
436                                {
437                                    load = Some(name.clone());
438                                }
439                            }
440                        },
441                    );
442                }
443                self.name = std::mem::take(&mut self.name)
444                    .placeholder("Screen name")
445                    .show_icon(false);
446                ui.allocate_ui(vec2(128.0, 28.0), |ui| {
447                    self.name.show(ui);
448                });
449                let named = !self.name.query().trim().is_empty();
450                let ready = self.saved.ready() && named;
451                save = Button::new("Save").enabled(ready).show(ui).clicked();
452                rename = Button::new("Rename")
453                    .enabled(ready && self.current.is_some())
454                    .show(ui)
455                    .clicked();
456                delete = Button::new("Delete")
457                    .enabled(self.saved.ready() && self.current.is_some())
458                    .show(ui)
459                    .clicked();
460            });
461            if let Some(name) = load {
462                self.load_screen(ui.ctx(), &name);
463            }
464            let name = self.name.query().trim().to_owned();
465            if save && self.saved.save(&name, self.saved_screen()).is_ok() {
466                self.current = Some(name.clone());
467            }
468            if rename
469                && let Some(old) = self.current.clone()
470                && old != name
471                && self.saved.save(&name, self.saved_screen()).is_ok()
472            {
473                self.renaming = Some(old);
474                self.current = Some(name);
475            }
476            if delete
477                && let Some(old) = self.current.clone()
478                && self.saved.delete(&old).is_ok()
479            {
480                self.current = None;
481            }
482        }
483        if self.stored
484            && let Some(error) = &self.saved.error
485        {
486            translated_status_message(
487                ui,
488                MessageKind::Error,
489                l10n::format_text(
490                    ui.ctx(),
491                    "Saved screens: {error}",
492                    &[("error", error.as_str())],
493                ),
494            );
495        }
496        if !columns.is_empty() {
497            ui.horizontal_wrapped(|ui| {
498                let (column, op, value) = &mut self.draft;
499                *column = (*column).min(columns.len() - 1);
500                dropdown(
501                    ui,
502                    "screener_filter_column",
503                    &columns[*column].title,
504                    |ui| {
505                        for (index, entry) in columns.iter().enumerate() {
506                            ui.selectable_value(column, index, l10n::text(ui.ctx(), &entry.title));
507                        }
508                    },
509                );
510                dropdown(ui, "screener_filter_op", op_label(*op), |ui| {
511                    for entry in [ScreenerOp::Above, ScreenerOp::Below] {
512                        ui.selectable_value(op, entry, l10n::text(ui.ctx(), op_label(entry)));
513                    }
514                });
515                ui.allocate_ui(vec2(128.0, 28.0), |ui| {
516                    value.show(ui);
517                });
518                let parsed = value
519                    .query()
520                    .trim()
521                    .parse::<f64>()
522                    .ok()
523                    .filter(|v| v.is_finite());
524                if Button::new("Add filter")
525                    .enabled(parsed.is_some())
526                    .show(ui)
527                    .clicked()
528                    && let Some(number) = parsed
529                {
530                    self.filters.push(ScreenerFilter {
531                        column: columns[*column].id.clone(),
532                        op: *op,
533                        value: number,
534                    });
535                    value.clear();
536                    changed = true;
537                }
538            });
539        }
540        let mut remove = None;
541        ui.horizontal_wrapped(|ui| {
542            for (index, filter) in self.filters.iter().enumerate() {
543                let text = format!(
544                    "{} {} {}  ×",
545                    title(&filter.column),
546                    op_label(filter.op),
547                    filter.value
548                );
549                if Button::new(text)
550                    .small()
551                    .show(ui)
552                    .on_hover_text(l10n::text(ui.ctx(), "Remove filter"))
553                    .clicked()
554                {
555                    remove = Some(index);
556                }
557            }
558        });
559        if let Some(index) = remove {
560            self.filters.remove(index);
561            changed = true;
562        }
563        match &self.error {
564            Some(FeedError::NotSupported) if !self.text.is_empty() => {
565                status_message(
566                    ui,
567                    MessageKind::Info,
568                    "This feed takes no screen descriptions.",
569                );
570            }
571            Some(FeedError::NotSupported) => {
572                status_message(ui, MessageKind::Info, "This feed supplies no screener.");
573            }
574            Some(error) => {
575                translated_status_message(
576                    ui,
577                    MessageKind::Error,
578                    l10n::format_text(
579                        ui.ctx(),
580                        "Screen failed: {error}",
581                        &[("error", error.to_string().as_str())],
582                    ),
583                );
584            }
585            None if self.loading && self.rows.is_empty() => {
586                LoadingIndicator::new().message("Screening").show(ui);
587            }
588            None if self.rows.is_empty() => {
589                EmptyState::new("No symbols pass these filters")
590                    .icon(&icons::WIDGET_BAR_SCREENER)
591                    .fill_height()
592                    .show(ui);
593            }
594            None => {}
595        }
596        let mut more = false;
597        let shown = self
598            .sets
599            .get(self.answered)
600            .map_or(&[][..], |set| &set.columns);
601        if !self.rows.is_empty() {
602            egui::ScrollArea::both()
603                .auto_shrink([false, false])
604                .show(ui, |ui| {
605                    ui.spacing_mut().item_spacing.y = 0.0;
606                    let width = ui.available_width().max(80.0 + shown.len() as f32 * 60.0);
607                    ui.set_min_width(width);
608                    let columns = |rect: egui::Rect| {
609                        let symbol = 80.0;
610                        let number = (width - symbol) / shown.len().max(1) as f32;
611                        std::iter::once(rect.with_max_x(rect.left() + symbol))
612                            .chain((0..shown.len()).map(|i| {
613                                rect.with_min_x(rect.left() + symbol + i as f32 * number)
614                                    .with_max_x(rect.left() + symbol + (i + 1) as f32 * number)
615                            }))
616                            .map(|rect| rect.shrink2(vec2(6.0, 0.0)))
617                            .collect::<Vec<_>>()
618                    };
619                    let (header, _) =
620                        ui.allocate_exact_size(vec2(width, ROW_HEIGHT), Sense::hover());
621                    let cells = columns(header);
622                    cell(ui, cells[0], "Symbol", false, theme.ui.text_secondary);
623                    for (index, column) in shown.iter().enumerate() {
624                        let sorted = self.sort_by.as_deref() == Some(column.id.as_str());
625                        let mut rect = cells[index + 1];
626                        if sorted {
627                            let icon = egui::Rect::from_center_size(
628                                egui::pos2(rect.right() - 6.0, rect.center().y),
629                                vec2(12.0, 12.0),
630                            );
631                            let chevron = if self.descending {
632                                &icons::CHEVRON_DOWN
633                            } else {
634                                &icons::CHEVRON_UP
635                            };
636                            chevron
637                                .as_image_tinted(icon.size(), theme.ui.text_secondary)
638                                .paint_at(ui, icon);
639                            rect.set_right(icon.left() - 2.0);
640                        }
641                        cell(ui, rect, &column.title, true, theme.ui.text_secondary);
642                        let response = ui.interact(
643                            cells[index + 1],
644                            ui.scope_id().with(("sort", &column.id)),
645                            Sense::click(),
646                        );
647                        let translated_label = l10n::text(ui.ctx(), &column.title);
648                        response.widget_info(|| {
649                            egui::WidgetInfo::labeled(egui::Role::Button, true, &translated_label)
650                        });
651                        if response
652                            .on_hover_text(l10n::text(ui.ctx(), "Sort by this column"))
653                            .clicked()
654                        {
655                            if sorted {
656                                self.descending = !self.descending;
657                            } else {
658                                self.sort_by = Some(column.id.clone());
659                                self.descending = true;
660                            }
661                            changed = true;
662                        }
663                    }
664                    for entry in &self.rows {
665                        let (rect, response) = row(ui);
666                        let cells = columns(rect);
667                        cell(ui, cells[0], &entry.symbol, false, theme.ui.text);
668                        for (rect, value) in cells.iter().skip(1).zip(&entry.values) {
669                            cell(ui, *rect, value, true, theme.ui.text);
670                        }
671                        let translated_label = l10n::text(ui.ctx(), &entry.symbol);
672                        response.widget_info(|| {
673                            egui::WidgetInfo::labeled(egui::Role::Button, true, &translated_label)
674                        });
675                        if response.clicked() {
676                            action = ScreenerAction::SelectSymbol(entry.symbol.clone());
677                        }
678                    }
679                    if self.more && !self.loading {
680                        more = Button::new("Show more").show(ui).clicked();
681                    }
682                });
683        }
684        if more {
685            self.load_more();
686        }
687        if changed {
688            self.refresh();
689        }
690        action
691    }
692}
693
694fn op_label(op: ScreenerOp) -> &'static str {
695    match op {
696        ScreenerOp::Above => ">",
697        ScreenerOp::Below => "<",
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use crate::data::{HistoryPage, HistoryRequest, Reply, ScreenerColumn};
705    use egui_kittest::{Harness, kittest::Queryable};
706    use std::sync::Mutex;
707
708    #[derive(Default)]
709    struct Feed {
710        rows: Mutex<Vec<(ScreenerQuery, Reply<Vec<ScreenerRow>>)>>,
711        text: Mutex<Vec<(ScreenerQuery, Reply<ScreenerTextAnswer>)>>,
712    }
713
714    #[derive(Default)]
715    struct Documents(Mutex<std::collections::BTreeMap<String, String>>);
716
717    impl Storage for Documents {
718        fn list(&self, _: StoreKind, reply: Reply<Vec<String>>) {
719            reply.send(Ok(self.0.lock().unwrap().keys().cloned().collect()));
720        }
721        fn load(&self, _: StoreKind, name: &str, reply: Reply<Option<String>>) {
722            reply.send(Ok(self.0.lock().unwrap().get(name).cloned()));
723        }
724        fn save(&self, _: StoreKind, name: &str, document: String, reply: Reply<()>) {
725            self.0.lock().unwrap().insert(name.into(), document);
726            reply.send(Ok(()));
727        }
728        fn delete(&self, _: StoreKind, name: &str, reply: Reply<()>) {
729            self.0.lock().unwrap().remove(name);
730            reply.send(Ok(()));
731        }
732    }
733
734    impl Datafeed for Feed {
735        fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
736            reply.send(Err(FeedError::NotSupported));
737        }
738        fn screen(&self, query: ScreenerQuery, reply: Reply<Vec<ScreenerRow>>) {
739            self.rows.lock().unwrap().push((query, reply));
740        }
741        fn screen_text(&self, query: ScreenerQuery, reply: Reply<ScreenerTextAnswer>) {
742            self.text.lock().unwrap().push((query, reply));
743        }
744    }
745
746    /// Guards what the feed is asked and what leaves the panel: the query names the shown set's
747    /// columns, the filters and the sort; the export heads the answered rows with that set's
748    /// titles even after the user switched sets, and quotes fields so a comma or quote in a value
749    /// cannot shift the columns.
750    #[test]
751    fn queries_carry_the_screen_and_exports_quote_the_answered_rows() {
752        let feed = Arc::new(Feed::default());
753        let mut panel = ScreenerPanel::new(vec![
754            ScreenerColumnSet::new(
755                "Overview",
756                vec![
757                    ScreenerColumn::new("price", "Price"),
758                    ScreenerColumn::new("name", "Name"),
759                ],
760            ),
761            ScreenerColumnSet::new("Volume", vec![ScreenerColumn::new("volume", "Vol")]),
762        ]);
763        panel.filters.push(ScreenerFilter {
764            column: "price".into(),
765            op: ScreenerOp::Above,
766            value: 10.0,
767        });
768        panel.sort_by = Some("price".into());
769        panel.set_feed(feed.clone());
770        let (query, reply) = feed.rows.lock().unwrap().pop().unwrap();
771        assert_eq!(query.columns, ["price", "name"]);
772        assert_eq!(query.filters, panel.filters);
773        assert_eq!(
774            (query.sort_by.as_deref(), query.descending),
775            (Some("price"), true)
776        );
777        reply.send(Ok(vec![ScreenerRow::new(
778            "A\"B",
779            vec!["1,234.50".into(), "Acme \"Co\"".into()],
780        )]));
781        panel.poll(&egui::Context::default());
782        panel.set = 1;
783        assert_eq!(
784            panel.csv(),
785            "\"Symbol\",\"Price\",\"Name\"\r\n\"A\"\"B\",\"1,234.50\",\"Acme \"\"Co\"\"\"\r\n"
786        );
787    }
788
789    fn sets() -> Vec<ScreenerColumnSet> {
790        vec![
791            ScreenerColumnSet::new("Overview", vec![ScreenerColumn::new("price", "Price")]),
792            ScreenerColumnSet::new("Volume", vec![ScreenerColumn::new("volume", "Vol")]),
793        ]
794    }
795
796    /// Guards the criteria of a screen: the universe and description go out with every request,
797    /// the next page repeats them with an offset, a failed page keeps the rows and the criteria,
798    /// and a saved screen restored in a new panel reproduces the complete request, also after a
799    /// rename.
800    #[test]
801    fn universe_description_and_saved_screens_reproduce_the_complete_request() {
802        let ctx = egui::Context::default();
803        let feed = Arc::new(Feed::default());
804        let documents = Arc::new(Documents::default());
805        let mut panel = ScreenerPanel::new(sets());
806        panel.set_universes(vec![
807            ScreenerUniverse::new("stock", "Stocks"),
808            ScreenerUniverse::new("coin", "Coins"),
809        ]);
810        assert_eq!(panel.universe.as_deref(), Some("stock"));
811        panel.universe = Some("coin".into());
812        panel.set = 1;
813        panel.text = "cheap and liquid".into();
814        panel.filters.push(ScreenerFilter {
815            column: "volume".into(),
816            op: ScreenerOp::Below,
817            value: 5.0,
818        });
819        panel.sort_by = Some("volume".into());
820        panel.descending = false;
821        panel.set_feed(feed.clone());
822        assert!(feed.rows.lock().unwrap().is_empty());
823        let (first, reply) = feed.text.lock().unwrap().pop().unwrap();
824        assert_eq!(
825            (first.universe.as_deref(), first.text.as_str(), first.offset),
826            (Some("coin"), "cheap and liquid", 0)
827        );
828        let page = |n: usize| {
829            (0..n)
830                .map(|i| ScreenerRow::new(format!("S{i}"), vec!["1".into()]))
831                .collect::<Vec<_>>()
832        };
833        reply.send(Ok(ScreenerTextAnswer::new(page(LIMIT), "volume below 5")));
834        panel.poll(&ctx);
835        assert!(panel.more && panel.explanation == "volume below 5");
836
837        panel.load_more();
838        let (next, reply) = feed.text.lock().unwrap().pop().unwrap();
839        assert_eq!(
840            (next.offset, ScreenerQuery { offset: 0, ..next }),
841            (LIMIT, first.clone())
842        );
843        reply.send(Err(FeedError::Network("down".into())));
844        panel.poll(&ctx);
845        assert_eq!(panel.rows.len(), LIMIT);
846        assert!(panel.error.is_some() && panel.explanation == "volume below 5");
847        panel.load_more();
848        let (_, reply) = feed.text.lock().unwrap().pop().unwrap();
849        reply.send(Ok(ScreenerTextAnswer::new(page(3), "volume below 5")));
850        panel.poll(&ctx);
851        assert_eq!((panel.rows.len(), panel.more), (LIMIT + 3, false));
852        assert!(panel.error.is_none());
853
854        panel.set_storage(documents.clone());
855        for _ in 0..3 {
856            panel.poll(&ctx);
857        }
858        panel.saved.save("Cheap", panel.saved_screen()).unwrap();
859        panel.poll(&ctx);
860        let mut restored = ScreenerPanel::new(sets());
861        restored.set_storage(documents.clone());
862        for _ in 0..4 {
863            restored.poll(&ctx);
864        }
865        restored.load_screen(&egui::Context::default(), "Cheap");
866        assert_eq!(restored.query(0), first);
867        // A feed without descriptions is told apart from one without a screener.
868        restored.set_feed(feed.clone());
869        let (_, reply) = feed.text.lock().unwrap().pop().unwrap();
870        reply.send(Err(FeedError::NotSupported));
871        restored.poll(&ctx);
872        let mut h = Harness::builder()
873            .with_context_setup(egui_extras::install_image_loaders)
874            .build_ui_state(|ui, panel: &mut ScreenerPanel| _ = panel.show(ui), restored);
875        h.run();
876        h.get_by_label("This feed takes no screen descriptions.");
877
878        panel.saved.save("Cheaper", panel.saved_screen()).unwrap();
879        panel.renaming = Some("Cheap".into());
880        for _ in 0..3 {
881            panel.poll(&ctx);
882        }
883        let names: Vec<_> = documents.0.lock().unwrap().keys().cloned().collect();
884        assert_eq!(names, ["Cheaper"]);
885    }
886}