1use 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
25const LIMIT: usize = 200;
27
28#[derive(Clone, Debug, PartialEq, Default)]
30#[non_exhaustive]
31pub enum ScreenerAction {
32 #[default]
34 None,
35 SelectSymbol(String),
37 ExportCsv(String),
39 SendToWatchlist(Vec<String>),
42}
43
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46struct SavedScreen {
47 universe: Option<String>,
48 set: String,
50 filters: Vec<ScreenerFilter>,
51 sort_by: Option<String>,
52 descending: bool,
53 text: String,
54}
55
56pub struct ScreenerPanel {
68 sets: Vec<ScreenerColumnSet>,
69 set: usize,
70 universes: Vec<ScreenerUniverse>,
71 universe: Option<String>,
72 text_query: bool,
73 description: FilterWidget,
75 text: String,
76 explanation: String,
77 saved: TemplateStore<SavedScreen>,
78 stored: bool,
79 current: Option<String>,
81 name: FilterWidget,
82 renaming: Option<String>,
84 more: bool,
85 appending: bool,
86 filters: Vec<ScreenerFilter>,
87 sort_by: Option<String>,
88 descending: bool,
89 draft: (usize, ScreenerOp, FilterWidget),
91 feed: Option<Arc<dyn Datafeed>>,
92 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 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 pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
140 self.feed = Some(feed);
141 self.refresh();
142 }
143
144 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 pub fn set_text_query(&mut self, enabled: bool) {
163 self.text_query = enabled;
164 }
165
166 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 pub fn refresh(&mut self) {
190 self.ask(0);
191 }
192
193 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 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 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 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 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 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 #[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 #[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 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}