1use crate::localization as l10n;
47mod compare;
48mod row;
49mod service;
50
51use crate::icons::icons;
52use crate::model::{Instrument, SymbolType};
53use crate::services::{Storage, SymbolExpression, SymbolSearch};
54use crate::theme::ThemeExt;
55use crate::ui_kit::buttons::{ButtonSize, IconButton};
56use crate::ui_kit::modal::Modal;
57use crate::ui_kit::tab_bar::{TabBar, TabLabel};
58use egui::{Context, RichText, Vec2};
59use row::{SEARCH_RADIUS, search_backdrop};
60use service::Search;
61use std::sync::Arc;
62
63pub use compare::{
64 CompareAction, ComparePlacement, CompareSymbol, CompareSymbolsConfig, CompareSymbolsDialog,
65};
66
67#[derive(Clone, Copy, PartialEq)]
72struct Category(Option<SymbolType>);
73
74impl TabLabel for Category {
75 fn tab_label(&self) -> &str {
76 match self.0 {
77 None => "All",
78 Some(SymbolType::Stock) => "Stocks",
79 Some(SymbolType::Index) => "Indices",
80 Some(SymbolType::Forex) => "Forex",
81 Some(SymbolType::Futures) => "Futures",
82 Some(SymbolType::Crypto) => "Crypto",
83 Some(SymbolType::Cfd) => "CFDs",
84 Some(SymbolType::Bond) => "Bonds",
85 Some(SymbolType::Fund) => "Funds",
86 Some(SymbolType::Option) => "Options",
87 Some(SymbolType::Commodity) => "Commodities",
88 Some(SymbolType::Economic) => "Economy",
89 Some(SymbolType::Dr) => "Depositary receipts",
90 Some(SymbolType::Right) => "Rights",
91 Some(SymbolType::Warrant) => "Warrants",
92 Some(SymbolType::Expression) => "Expressions",
93 Some(SymbolType::Spread) => "Spreads",
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
115pub struct SymbolSearchConfig {
116 pub show_exchange: bool,
118 pub show_description: bool,
120 pub max_results: usize,
122 pub min_search_len: usize,
124}
125
126impl Default for SymbolSearchConfig {
127 fn default() -> Self {
128 Self {
129 show_exchange: true,
130 show_description: true,
131 max_results: 20,
132 min_search_len: 1,
133 }
134 }
135}
136
137pub struct SymbolSearchDialog {
170 is_open: bool,
171 focus_search: bool,
172 select_query: bool,
173 search_query: String,
174 sel_idx: usize,
175 filtered_symbols: Vec<Instrument>,
176 category: Category,
177 recent_symbols: Vec<String>,
178 sources: Vec<String>,
179 feed_exchanges: Option<Vec<String>>,
181 feed_symbol_types: Option<Vec<SymbolType>>,
182 expanded: Vec<String>,
183 expression_status: Option<(String, bool)>,
185 config: SymbolSearchConfig,
186 search: Search,
187}
188
189#[derive(Clone, Copy)]
192struct Row {
193 index: usize,
194 members: usize,
195 nested: bool,
196}
197
198impl Default for SymbolSearchDialog {
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204impl SymbolSearchDialog {
205 pub fn new() -> Self {
207 Self {
208 is_open: false,
209 focus_search: true,
210 select_query: false,
211 search_query: String::new(),
212 sel_idx: 0,
213 filtered_symbols: Vec::new(),
214 category: Category(None),
215 recent_symbols: Vec::new(),
216 sources: Vec::new(),
217 feed_exchanges: None,
218 feed_symbol_types: None,
219 expanded: Vec::new(),
220 expression_status: None,
221 config: SymbolSearchConfig::default(),
222 search: Search::default(),
223 }
224 }
225
226 pub fn with_config(mut self, config: SymbolSearchConfig) -> Self {
241 self.config = config;
242 self
243 }
244
245 pub fn set_search_service(&mut self, service: Option<Arc<dyn SymbolSearch>>) {
247 self.search.set_service(service);
248 }
249
250 pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
252 self.search.recents.set_storage(storage);
253 }
254
255 pub fn set_exchange_filter(&mut self, exchange: Option<String>) {
257 self.search.exchange = exchange;
258 }
259
260 pub fn set_sources(&mut self, sources: Vec<String>) {
264 self.sources = sources;
265 }
266
267 pub fn set_feed_capabilities(&mut self, capabilities: &crate::data::FeedCapabilities) {
273 self.feed_exchanges = capabilities.exchanges.clone();
274 self.feed_symbol_types = capabilities.symbol_types.clone();
275 }
276
277 pub fn record_symbol(&mut self, symbol: &Instrument) {
279 self.search.record(symbol);
280 }
281
282 pub fn set_recent_symbols(&mut self, symbols: Vec<String>) {
284 self.recent_symbols = symbols;
285 }
286
287 pub fn open(&mut self) {
291 self.is_open = true;
292 self.focus_search = true;
293 self.select_query = false;
294 self.search_query.clear();
295 self.sel_idx = 0;
296 self.category = Category(None);
297 self.expanded.clear();
298 self.filtered_symbols.clear();
299 self.search.reset();
300 }
301
302 pub fn open_with_query(&mut self, query: String) {
304 self.open();
305 self.search_query = query;
306 }
307
308 pub fn open_with_selected_query(&mut self, query: String) {
312 self.open_with_query(query);
313 self.select_query = true;
314 }
315
316 pub fn close(&mut self) {
318 self.is_open = false;
319 }
320
321 pub fn is_open(&self) -> bool {
323 self.is_open
324 }
325
326 #[must_use = "Instrument selection should be handled"]
363 pub fn show(&mut self, ctx: &Context, all_symbols: &[Instrument]) -> Option<Instrument> {
364 self.search.poll(ctx);
365 if self.search.has_recents() {
366 self.recent_symbols = self.search.recent_names();
367 }
368 if !self.is_open {
369 return None;
370 }
371
372 self.refresh_results(ctx, all_symbols);
373 let mut sel_symbol = None;
374 let mut should_close = false;
375 let search_id = egui::Id::unique("symbol_search_input");
376 let focus_search = self.focus_search;
377 let search_focused = ctx.memory(|m| m.has_focus(search_id));
378 let (up, down) = ctx.input(|input| {
379 (
380 search_focused && input.key_pressed(egui::Key::ArrowUp),
381 search_focused && input.key_pressed(egui::Key::ArrowDown),
382 )
383 });
384 let keyboard_move = up || down;
385 let mut rows = self.rows();
386 self.sel_idx = self.sel_idx.min(rows.len().saturating_sub(1));
387 if !rows.is_empty() {
388 if down {
389 self.sel_idx = (self.sel_idx + 1) % rows.len();
390 }
391 if up {
392 self.sel_idx = (self.sel_idx + rows.len() - 1) % rows.len();
393 }
394 }
395
396 let mut open = true;
397 Modal::new("symbol_search", "")
398 .width(840.0)
399 .height(680.0)
400 .rounding(SEARCH_RADIUS)
401 .backdrop(search_backdrop(ctx))
402 .show(ctx, &mut open, |ui| {
403 let translated_label = l10n::text(ui.ctx(), "Search symbols");
404 ui.response().widget_info(|| {
405 egui::WidgetInfo::labeled(egui::Role::Dialog, true, &translated_label)
406 });
407 ui.ctx()
408 .accesskit_node_builder(ui.response().id, |node| node.set_modal());
409 let theme = ui.active_theme();
410 ui.horizontal(|ui| {
411 ui.add(
412 icons::QUICK_SEARCH
413 .as_image_tinted(Vec2::splat(24.0), theme.ui.text_secondary),
414 );
415 let search_id = egui::Id::unique("symbol_search_input");
416 if focus_search {
417 ui.memory_mut(|memory| memory.request_focus(search_id));
418 }
419 let has_query = !self.search_query.is_empty();
420 let mut field = egui::TextEdit::singleline(&mut self.search_query)
421 .id(search_id)
422 .font(egui::FontId::proportional(16.0))
423 .hint_text(RichText::new(l10n::text(ui.ctx(), "Search symbols")).size(16.0))
424 .frame(egui::Frame::NONE)
425 .desired_width(
426 (ui.available_width() - if has_query { 72.0 } else { 40.0 }).max(0.0),
427 )
428 .show(ui);
429 let response = field
430 .response
431 .response
432 .accessible_name(l10n::text(ui.ctx(), "Search symbols"));
433 if has_query
434 && IconButton::new(&icons::CLOSE)
435 .size(ButtonSize::SM)
436 .tooltip("Clear search")
437 .show(ui)
438 .clicked()
439 {
440 self.search_query.clear();
441 self.sel_idx = 0;
442 self.refresh_results(ctx, all_symbols);
443 response.request_focus();
444 }
445 if IconButton::new(&icons::CLOSE)
446 .tooltip("Close")
447 .show(ui)
448 .clicked()
449 {
450 should_close = true;
451 }
452 if focus_search {
454 response.request_focus();
455 if !ui.is_sizing_pass() {
456 self.focus_search = false;
457 if std::mem::take(&mut self.select_query) {
458 field.state.cursor.set_char_range(Some(
459 egui::text::CCursorRange::select_all(&field.galley),
460 ));
461 field.state.store(ui.ctx(), response.id);
462 }
463 }
464 }
465 if response.changed() {
466 self.sel_idx = 0;
467 self.refresh_results(ctx, all_symbols);
468 }
469 });
470 ui.add_space(16.0);
471 let mut categories = vec![Category(None)];
472 if let Some(types) = &self.feed_symbol_types {
473 categories.extend(types.iter().map(|kind| Category(Some(*kind))));
474 } else if self.search.connected() {
475 categories.extend(
476 [
477 SymbolType::Stock,
478 SymbolType::Index,
479 SymbolType::Forex,
480 SymbolType::Futures,
481 SymbolType::Crypto,
482 SymbolType::Cfd,
483 SymbolType::Bond,
484 SymbolType::Fund,
485 SymbolType::Option,
486 SymbolType::Commodity,
487 SymbolType::Economic,
488 SymbolType::Dr,
489 SymbolType::Right,
490 SymbolType::Warrant,
491 SymbolType::Expression,
492 SymbolType::Spread,
493 ]
494 .map(|kind| Category(Some(kind))),
495 );
496 } else {
497 for symbol in all_symbols {
498 let category = Category(symbol.symbol_type);
499 if !categories.contains(&category) {
500 categories.push(category);
501 }
502 }
503 }
504 if !categories.contains(&self.category) {
505 self.category = Category(None);
506 }
507 let mut sources = self
508 .feed_exchanges
509 .clone()
510 .unwrap_or_else(|| self.sources.clone());
511 if self.feed_exchanges.is_none() && !self.search.connected() {
512 for exchange in all_symbols.iter().filter_map(|s| s.exchange.as_ref()) {
513 if !sources.contains(exchange) {
514 sources.push(exchange.clone());
515 }
516 }
517 }
518 let before_category = self.category;
519 let before_exchange = self.search.exchange.clone();
520 ui.horizontal(|ui| {
521 let source_width = if sources.is_empty() {
522 0.0
523 } else {
524 120.0 + ui.spacing().item_spacing.x
525 };
526 let tabs_width = (ui.available_width() - source_width).max(0.0);
527 ui.allocate_ui_with_layout(
528 Vec2::new(tabs_width, 32.0),
529 egui::Layout::top_down(egui::Align::Min),
530 |ui| {
531 TabBar::new(&categories, &mut self.category)
532 .pills()
533 .single_row()
534 .show(ui);
535 },
536 );
537 if !sources.is_empty() {
538 let all = l10n::text(ui.ctx(), "All sources");
539 egui::ComboBox::from_id_salt("symbol_sources")
540 .width(120.0)
541 .truncate()
542 .selected_text(self.search.exchange.as_deref().unwrap_or(&all))
543 .show_ui(ui, |ui| {
544 ui.selectable_value(&mut self.search.exchange, None, all.as_str());
545 for source in sources {
546 let label = source.clone();
547 ui.selectable_value(
548 &mut self.search.exchange,
549 Some(source),
550 label,
551 );
552 }
553 })
554 .response
555 .accessible_name(l10n::text(ui.ctx(), "Source"));
556 }
557 });
558 if self.category != before_category || self.search.exchange != before_exchange {
559 self.sel_idx = 0;
560 self.refresh_results(ctx, all_symbols);
561 }
562 rows = self.rows();
563 ui.add_space(theme.spacing.xl);
564
565 self.search.status(ui);
566 if let Some((status, error)) = &self.expression_status {
567 let status = l10n::text(ui.ctx(), status);
568 if *error {
569 ui.colored_label(ui.visuals().error_fg_color, status);
570 } else {
571 ui.weak(status);
572 }
573 }
574 if self.search_query.is_empty()
575 && self.category.0.is_none()
576 && self.search.exchange.is_none()
577 && self.search.has_recents()
578 {
579 ui.weak(l10n::text(ui.ctx(), "Recently opened"));
580 }
581 let mut next_page = false;
582 egui::ScrollArea::vertical()
584 .id_salt("symbol_results")
585 .auto_shrink([false, false])
586 .max_height((ui.available_height() - 36.0).max(0.0))
587 .show(ui, |ui| {
588 ui.spacing_mut().item_spacing.y = 0.0;
589 if self.filtered_symbols.is_empty() {
590 if !self.search_query.is_empty()
591 && self.search_query.len() < self.config.min_search_len
592 {
593 ui.label(l10n::format_text(
594 ui.ctx(),
595 "Type at least {0} {1} to search",
596 &[
597 ("0", format!("{}", self.config.min_search_len)),
598 (
599 "1",
600 (if self.config.min_search_len == 1 {
601 "character"
602 } else {
603 "characters"
604 })
605 .to_string(),
606 ),
607 ],
608 ));
609 } else {
610 ui.label(l10n::text(ui.ctx(), "No symbols found"));
611 }
612 } else {
613 let mut toggled = None;
614 for (idx, row) in rows.iter().enumerate() {
615 let is_sel = idx == self.sel_idx;
616 let symbol = &self.filtered_symbols[row.index];
617 let (response, toggle) =
618 self.render_symbol_item(ui, symbol, is_sel, *row);
619 next_page |=
620 idx + 1 == rows.len() && ui.is_rect_visible(response.rect);
621 if toggle {
622 toggled = symbol.root.clone();
623 }
624
625 if response.clicked() {
626 sel_symbol = Some(symbol.clone());
627 should_close = true;
628 }
629
630 if keyboard_move && is_sel {
631 response.scroll_to_me(Some(egui::Align::Center));
632 }
633 if !keyboard_move
634 && response.hovered()
635 && ui.input(|input| input.pointer.delta() != Vec2::ZERO)
636 {
637 self.sel_idx = idx;
638 }
639 }
640 if let Some(root) = toggled {
641 if let Some(at) = self.expanded.iter().position(|r| *r == root) {
642 self.expanded.remove(at);
643 } else {
644 self.expanded.push(root);
645 }
646 }
647 }
648 });
649
650 if next_page {
651 self.search.next_page();
652 }
653 ui.add_space(8.0);
654 ui.horizontal(|ui| {
655 ui.spacing_mut().item_spacing.x = 8.0;
656 for (key, action) in
657 [("↑ ↓", "Navigate"), ("Enter", "Select"), ("Esc", "Close")]
658 {
659 ui.label(
660 RichText::new(l10n::text(ui.ctx(), key))
661 .monospace()
662 .size(11.0),
663 );
664 ui.label(
665 RichText::new(l10n::text(ui.ctx(), action))
666 .size(11.0)
667 .weak(),
668 );
669 ui.add_space(12.0);
670 }
671 });
672
673 ui.input(|i| {
675 if search_focused
676 && i.key_pressed(egui::Key::Enter)
677 && let Some(row) = rows.get(self.sel_idx)
678 {
679 sel_symbol = Some(self.filtered_symbols[row.index].clone());
680 should_close = true;
681 }
682 });
683 });
684
685 if should_close || !open {
686 self.close();
687 }
688
689 if let Some(symbol) = &sel_symbol {
690 self.search.record(symbol);
691 }
692 sel_symbol
693 }
694
695 fn refresh_results(&mut self, ctx: &Context, all_symbols: &[Instrument]) {
696 if self.search.connected() {
697 if self.search_query.is_empty()
698 || self.search_query.chars().count() >= self.config.min_search_len
699 {
700 self.search.query(
701 ctx,
702 &self.search_query,
703 self.category.0,
704 self.config.max_results,
705 );
706 self.filtered_symbols = self.search.results();
707 } else {
708 self.search.reset();
709 self.filtered_symbols.clear();
710 }
711 self.sel_idx = self
712 .sel_idx
713 .min(self.filtered_symbols.len().saturating_sub(1));
714 } else {
715 self.update_filtered_symbols(all_symbols);
716 }
717 self.list_expression(ctx, all_symbols);
718 }
719
720 fn list_expression(&mut self, ctx: &Context, all_symbols: &[Instrument]) {
723 self.expression_status = None;
724 let text = self.search_query.trim();
725 if self.config.max_results == 0
726 || self.search_query.chars().count() < self.config.min_search_len
727 || !text.contains(['+', '-', '*', '/', '(', ')'])
728 || !matches!(self.category.0, None | Some(SymbolType::Spread))
729 || self
730 .filtered_symbols
731 .iter()
732 .any(|symbol| symbol.symbol.eq_ignore_ascii_case(text))
733 {
734 return;
735 }
736 let expression = match SymbolExpression::parse(text) {
737 Ok(expression) if !expression.is_spread() => return,
738 Ok(expression) => self.search.resolve(ctx, text, expression, all_symbols),
739 Err(error) => Some(Err(error.to_string())),
740 };
741 match expression {
742 Some(Ok(expression)) => {
743 self.filtered_symbols.insert(
744 0,
745 Instrument::new(expression.to_string()).with_symbol_type(SymbolType::Spread),
746 );
747 self.filtered_symbols.truncate(self.config.max_results);
748 }
749 Some(Err(error)) => self.expression_status = Some((error, true)),
750 None => self.expression_status = Some(("Checking symbols…".into(), false)),
751 }
752 }
753
754 fn update_filtered_symbols(&mut self, all_symbols: &[Instrument]) {
755 if !self.search_query.is_empty() && self.search_query.len() < self.config.min_search_len {
756 self.filtered_symbols.clear();
757 return;
758 }
759
760 let query_lower = self.search_query.to_lowercase();
761
762 let mut filtered: Vec<_> = all_symbols
763 .iter()
764 .filter(|symbol| {
765 (self.category.0.is_none() || symbol.symbol_type == self.category.0)
766 && (self.search.exchange.is_none() || symbol.exchange == self.search.exchange)
767 && (symbol.symbol.to_lowercase().contains(&query_lower)
768 || symbol
769 .description
770 .as_deref()
771 .unwrap_or(&symbol.symbol)
772 .to_lowercase()
773 .contains(&query_lower))
774 })
775 .collect();
776
777 if self.search_query.is_empty() {
778 filtered.sort_by_key(|symbol| {
779 self.recent_symbols
780 .iter()
781 .position(|name| name == &symbol.symbol)
782 .unwrap_or(usize::MAX)
783 });
784 }
785 self.filtered_symbols = filtered
786 .into_iter()
787 .take(self.config.max_results)
788 .cloned()
789 .collect();
790 self.sel_idx = self
791 .sel_idx
792 .min(self.filtered_symbols.len().saturating_sub(1));
793 }
794
795 fn rows(&self) -> Vec<Row> {
798 let mut rows = Vec::new();
799 let symbols = &self.filtered_symbols;
800 for (index, symbol) in symbols.iter().enumerate() {
801 let Some(root) = &symbol.root else {
802 rows.push(Row {
803 index,
804 members: 0,
805 nested: false,
806 });
807 continue;
808 };
809 if symbols[..index]
810 .iter()
811 .any(|s| s.root.as_ref() == Some(root))
812 {
813 continue;
814 }
815 let members: Vec<_> = (index + 1..symbols.len())
816 .filter(|&i| symbols[i].root.as_ref() == Some(root))
817 .collect();
818 rows.push(Row {
819 index,
820 members: members.len(),
821 nested: false,
822 });
823 if self.expanded.contains(root) {
824 rows.extend(members.into_iter().map(|index| Row {
825 index,
826 members: 0,
827 nested: true,
828 }));
829 }
830 }
831 rows
832 }
833}
834
835#[cfg(test)]
840mod tests {
841 use super::*;
842 use egui::Pos2;
843
844 pub(super) fn create_test_symbols() -> Vec<Instrument> {
845 vec![
846 Instrument::new("BTCUSDT").with_description("Bitcoin / Tether"),
847 Instrument::new("ETHUSDT").with_description("Ethereum / Tether"),
848 Instrument::new("AAPL").with_description("Apple Inc."),
849 Instrument::new("TSLA").with_description("Tesla Inc."),
850 ]
851 }
852
853 #[test]
854 fn service_results_reach_both_symbol_choosers_without_a_local_catalog() {
855 for compare in [false, true] {
856 let ctx = Context::default();
857 crate::theme::apply_to_egui(&ctx, &crate::theme::Theme::light());
858 let source: Arc<dyn SymbolSearch> = Arc::new(vec![
859 crate::model::Instrument::new("HOST").with_description("Host instrument"),
860 ]);
861 let mut search = SymbolSearchDialog::new();
862 search.set_search_service(Some(source.clone()));
863 search.set_feed_capabilities(&crate::data::FeedCapabilities {
864 exchanges: Some(vec!["Host venue".into()]),
865 ..Default::default()
866 });
867 search.open_with_query("host".into());
868 let mut comparison = CompareSymbolsDialog::new();
869 comparison.set_search_service(Some(source));
870 comparison.open();
871 comparison.search_query = "host".into();
872 for step in 0..3 {
873 let output = ctx.run_ui(
874 egui::RawInput {
875 screen_rect: Some(egui::Rect::from_min_size(
876 Pos2::ZERO,
877 Vec2::new(1440.0, 900.0),
878 )),
879 ..Default::default()
880 },
881 |ui| {
882 if compare {
883 let _ = comparison.show(ui.ctx(), &[]);
884 } else {
885 let _ = search.show(ui.ctx(), &[]);
886 }
887 },
888 );
889 let painted = output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == "HOST"));
890 if step > 0 {
891 assert!(painted, "service result absent from chooser {compare}");
892 if !compare {
893 let name = output
894 .shapes
895 .iter()
896 .find_map(|shape| match &shape.shape {
897 egui::Shape::Text(text) if text.galley.text() == "HOST" => {
898 Some(text.pos)
899 }
900 _ => None,
901 })
902 .unwrap();
903 assert!(
904 (name.y - 237.0).abs() <= 4.0,
905 "result row must start near y=229: {name:?}"
906 );
907 }
908 }
909 output.drop_without_applying_deltas();
910 }
911 }
912 }
913
914 #[test]
915 fn test_symbol_search_open_close() {
916 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
917 for keyboard in [false, true] {
918 let ctx = Context::default();
919 crate::theme::apply_to_egui(&ctx, &theme);
920 let mut dialog = SymbolSearchDialog::new();
921 assert!(!dialog.is_open());
922 dialog.open();
923 let mut frame = |events| {
924 let mut picked = None;
925 ctx.run_ui(
926 egui::RawInput {
927 screen_rect: Some(egui::Rect::from_min_size(
928 Pos2::ZERO,
929 Vec2::new(1440.0, 900.0),
930 )),
931 events,
932 ..Default::default()
933 },
934 |ui| {
935 picked = dialog.show(ui.ctx(), &create_test_symbols());
936 },
937 )
938 .drop_without_applying_deltas();
939 picked
940 };
941 frame(vec![]);
942 frame(vec![]);
943 if keyboard {
944 for key in [egui::Key::Tab, egui::Key::Enter] {
945 assert!(
946 frame(vec![egui::Event::Key {
947 key,
948 physical_key: None,
949 pressed: true,
950 repeat: false,
951 modifiers: egui::Modifiers::NONE,
952 }])
953 .is_none(),
954 "closing must not pick a symbol"
955 );
956 }
957 } else {
958 let pos = egui::pos2(10.0, 850.0);
959 frame(vec![egui::Event::PointerMoved(pos)]);
960 for pressed in [true, false] {
961 frame(vec![egui::Event::PointerButton {
962 pos,
963 button: egui::PointerButton::Primary,
964 pressed,
965 modifiers: Default::default(),
966 }]);
967 }
968 }
969 assert!(
970 !dialog.is_open(),
971 "backdrop and keyboard dismissal close symbol search"
972 );
973 dialog.open();
974 assert!(dialog.is_open());
975 dialog.close();
976 assert!(!dialog.is_open());
977 }
978 }
979 }
980
981 #[test]
982 fn keyboard_selection_survives_a_stationary_pointer_and_scrolls() {
983 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
984 let ctx = Context::default();
985 crate::theme::apply_to_egui(&ctx, &theme);
986 let symbols: Vec<_> = (0..20)
987 .map(|i| Instrument::new(format!("S{i}")).with_description("Sample"))
988 .collect();
989 let mut dialog = SymbolSearchDialog::new();
990 ctx.run_ui(Default::default(), |ui| {
991 let row = Row {
992 index: 0,
993 members: 0,
994 nested: false,
995 };
996 let (response, _) = dialog.render_symbol_item(ui, &symbols[0], false, row);
997 assert_eq!(response.rect.height(), 56.0);
998 })
999 .drop_without_applying_deltas();
1000 dialog.open();
1001 let mut frame = |events| {
1002 let mut picked = None;
1003 let mut visible = Vec::new();
1004 let output = ctx.run_ui(
1005 egui::RawInput {
1006 screen_rect: Some(egui::Rect::from_min_size(
1007 Pos2::ZERO,
1008 Vec2::new(600.0, 400.0),
1009 )),
1010 events,
1011 ..Default::default()
1012 },
1013 |ui| {
1014 picked = dialog.show(ui.ctx(), &symbols);
1015 },
1016 );
1017 for shape in &output.shapes {
1018 if let egui::Shape::Text(text) = &shape.shape {
1019 let rect = egui::Rect::from_min_size(text.pos, text.galley.size());
1020 if shape.clip_rect.contains_rect(rect) {
1021 visible.push((text.galley.text().to_owned(), rect.center()));
1022 }
1023 }
1024 }
1025 output.drop_without_applying_deltas();
1026 (picked, visible, dialog.sel_idx)
1027 };
1028 frame(vec![]);
1029 let (_, visible, _) = frame(vec![]);
1030 let pos = visible.iter().find(|(text, _)| text == "S0").unwrap().1;
1031 frame(vec![egui::Event::PointerMoved(pos)]);
1032 let key = |key, pressed| egui::Event::Key {
1033 key,
1034 physical_key: None,
1035 pressed,
1036 repeat: false,
1037 modifiers: Default::default(),
1038 };
1039 for _ in 0..18 {
1040 frame(vec![key(egui::Key::ArrowDown, true)]);
1041 frame(vec![key(egui::Key::ArrowDown, false)]);
1042 }
1043 for _ in 0..30 {
1044 frame(vec![]);
1045 }
1046 let visible = frame(vec![]);
1047 assert!(
1048 visible.1.iter().any(|(text, _)| text == "S18"),
1049 "{visible:?}"
1050 );
1051 let picked = frame(vec![key(egui::Key::Enter, true)]).0.unwrap();
1052 assert_eq!(picked.symbol, "S18");
1053 dialog.open();
1054 ctx.run_ui(
1055 egui::RawInput {
1056 events: vec![key(egui::Key::Escape, true)],
1057 ..Default::default()
1058 },
1059 |ui| {
1060 let _ = dialog.show(ui.ctx(), &symbols);
1061 },
1062 )
1063 .drop_without_applying_deltas();
1064 assert!(!dialog.is_open());
1065 }
1066 }
1067
1068 #[test]
1069 fn opening_with_a_selected_query_replaces_it_and_clear_restores_results() {
1070 for (query, selected) in [("BTCUSDT", true), ("ÉTH", true), ("TS", false)] {
1071 let ctx = Context::default();
1072 crate::theme::apply_to_egui(&ctx, &crate::theme::Theme::light());
1073 let mut dialog = SymbolSearchDialog::new();
1074 if selected {
1075 dialog.open_with_selected_query(query.into());
1076 } else {
1077 dialog.open_with_query(query.into());
1078 }
1079 let symbols = create_test_symbols();
1080 let mut frame = |events| {
1081 let output = ctx.run_ui(
1082 egui::RawInput {
1083 screen_rect: Some(egui::Rect::from_min_size(
1084 Pos2::ZERO,
1085 Vec2::new(1440.0, 900.0),
1086 )),
1087 events,
1088 ..Default::default()
1089 },
1090 |ui| {
1091 let _ = dialog.show(ui.ctx(), &symbols);
1092 },
1093 );
1094 let query = dialog.search_query.clone();
1095 output.drop_without_applying_deltas();
1096 query
1097 };
1098 frame(vec![]);
1099 frame(vec![]);
1100 let state =
1101 egui::TextEdit::load_state(&ctx, egui::Id::unique("symbol_search_input")).unwrap();
1102 let range = state.cursor.char_range().unwrap().as_sorted_char_range();
1103 assert_eq!(
1104 range == (egui::text::CharIndex::ZERO..query.chars().count().into()),
1105 selected
1106 );
1107 let typed = if selected {
1109 "AAPL".to_owned()
1110 } else {
1111 format!("{query}AAPL")
1112 };
1113 assert_eq!(frame(vec![egui::Event::Text("AAPL".into())]), typed);
1114 let clear = egui::pos2(1076.0, 143.0);
1116 frame(vec![egui::Event::PointerMoved(clear)]);
1117 for pressed in [true, false] {
1118 frame(vec![egui::Event::PointerButton {
1119 pos: clear,
1120 button: egui::PointerButton::Primary,
1121 pressed,
1122 modifiers: Default::default(),
1123 }]);
1124 }
1125 assert!(dialog.search_query.is_empty());
1126 assert_eq!(dialog.filtered_symbols.len(), symbols.len());
1127 assert_eq!(
1128 ctx.memory(|m| m.focused()),
1129 Some(egui::Id::unique("symbol_search_input"))
1130 );
1131 }
1132 }
1133
1134 #[test]
1135 fn result_rows_highlight_substrings_and_truncate_long_descriptions() {
1136 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1137 for (query, symbol, description, marked) in [
1138 ("sa", "SAMPLE", "A sample description", "SAsa"),
1139 ("é", "ÉTH", "Éther", "ÉÉ"),
1140 ("i", "İND", "İstanbul", "İİ"),
1141 ("", "SAMPLE", "A long description ", ""),
1142 ] {
1143 let ctx = Context::default();
1144 crate::theme::apply_to_egui(&ctx, &theme);
1145 let mut dialog = SymbolSearchDialog::new();
1146 dialog.search_query = query.into();
1147 let instrument = Instrument::new(symbol)
1148 .with_description(format!("{description}{}", " long".repeat(60)));
1149 let mut row_rect = egui::Rect::NOTHING;
1150 let output = ctx.run_ui(
1151 egui::RawInput {
1152 screen_rect: Some(egui::Rect::from_min_size(
1153 Pos2::ZERO,
1154 Vec2::new(400.0, 150.0),
1155 )),
1156 ..Default::default()
1157 },
1158 |ui| {
1159 (row_rect, _) = {
1160 let (response, toggled) = dialog.render_symbol_item(
1161 ui,
1162 &instrument,
1163 false,
1164 Row {
1165 index: 0,
1166 members: 0,
1167 nested: false,
1168 },
1169 );
1170 (response.rect, toggled)
1171 };
1172 },
1173 );
1174 let mut highlights = String::new();
1175 let mut elided = false;
1176 for shape in &output.shapes {
1177 if let egui::Shape::Text(text) = &shape.shape {
1178 let job = &text.galley.job;
1179 for section in &job.sections {
1180 if section.format.color == theme.ui.accent {
1181 highlights.push_str(
1182 &job.text[section.byte_range.start.0..section.byte_range.end.0],
1183 );
1184 }
1185 }
1186 elided |= text.galley.elided;
1187 assert!(row_rect.contains_rect(egui::Rect::from_min_size(
1188 text.pos,
1189 text.galley.size()
1190 )));
1191 }
1192 }
1193 assert_eq!(highlights, marked);
1194 assert!(elided);
1195 output.drop_without_applying_deltas();
1196 }
1197 }
1198 }
1199
1200 #[test]
1201 fn test_symbol_filtering() {
1202 let symbols: Vec<_> = create_test_symbols()
1203 .into_iter()
1204 .map(|symbol| {
1205 let (kind, exchange) = if symbol.symbol.ends_with("USDT") {
1206 (SymbolType::Crypto, "Binance")
1207 } else {
1208 (SymbolType::Stock, "NASDAQ")
1209 };
1210 symbol.with_exchange(exchange).with_symbol_type(kind)
1211 })
1212 .collect();
1213 for (query, category, exchange, recents, limit, expected) in [
1214 ("BTC", None, None, vec![], 20, vec!["BTCUSDT"]),
1215 (
1216 "",
1217 None,
1218 None,
1219 vec![],
1220 20,
1221 vec!["BTCUSDT", "ETHUSDT", "AAPL", "TSLA"],
1222 ),
1223 (
1224 "",
1225 None,
1226 None,
1227 vec!["TSLA", "missing", "ETHUSDT"],
1228 2,
1229 vec!["TSLA", "ETHUSDT"],
1230 ),
1231 (
1232 "",
1233 Some(SymbolType::Stock),
1234 None,
1235 vec!["ETHUSDT", "TSLA"],
1236 20,
1237 vec!["TSLA", "AAPL"],
1238 ),
1239 ("bitcoin", Some(SymbolType::Stock), None, vec![], 20, vec![]),
1240 ("a", None, None, vec![], 20, vec![]),
1241 ("", None, Some("NASDAQ"), vec![], 20, vec!["AAPL", "TSLA"]),
1242 ("btc", None, Some("NASDAQ"), vec![], 20, vec![]),
1243 ] {
1244 let mut dialog = SymbolSearchDialog::new();
1245 dialog.config.min_search_len = 2;
1246 dialog.config.max_results = limit;
1247 dialog.category = Category(category);
1248 dialog.set_exchange_filter(exchange.map(str::to_owned));
1249 dialog.set_recent_symbols(recents.into_iter().map(str::to_owned).collect());
1250 dialog.search_query = query.to_owned();
1251 dialog.update_filtered_symbols(&symbols);
1252 assert_eq!(
1253 dialog
1254 .filtered_symbols
1255 .iter()
1256 .map(|s| s.symbol.as_str())
1257 .collect::<Vec<_>>(),
1258 expected,
1259 "{query:?} / {category:?} / {exchange:?}"
1260 );
1261 }
1262 }
1263
1264 #[test]
1267 fn spread_queries_list_the_expression_or_report_why_not() {
1268 let symbols = create_test_symbols();
1269 for (query, min_length, limit, expected, status) in [
1270 ("aapl - tsla", 1, 20, Some("AAPL-TSLA"), None),
1271 (
1272 "(btcusdt+ethusdt)/2",
1273 1,
1274 20,
1275 Some("(BTCUSDT+ETHUSDT)/2"),
1276 None,
1277 ),
1278 ("aapl-xyz", 1, 20, None, Some("Unknown symbol xyz")),
1279 ("aapl-", 1, 20, None, Some("A symbol or number is missing")),
1280 ("aapl-tsla", 20, 20, None, None),
1281 ("aapl-tsla", 1, 0, None, None),
1282 ] {
1283 let ctx = Context::default();
1284 let mut dialog = SymbolSearchDialog::new();
1285 dialog.config.min_search_len = min_length;
1286 dialog.config.max_results = limit;
1287 dialog.search_query = query.into();
1288 dialog.refresh_results(&ctx, &symbols);
1289 let spread = dialog
1290 .filtered_symbols
1291 .first()
1292 .filter(|symbol| symbol.symbol_type == Some(SymbolType::Spread));
1293 assert_eq!(spread.map(|s| s.symbol.as_str()), expected, "{query}");
1294 let shown = dialog.expression_status.as_ref();
1295 assert_eq!(shown.map(|(text, _)| text.as_str()), status, "{query}");
1296 }
1297 }
1298
1299 #[test]
1302 fn roots_expand_to_their_instruments_and_pick_the_exact_one() {
1303 let contract = |symbol: &str| {
1304 Instrument::new(symbol)
1305 .with_root("ES")
1306 .with_exchange("CME")
1307 .with_symbol_type(SymbolType::Futures)
1308 };
1309 let symbols = vec![
1310 contract("ES1!"),
1311 Instrument::new("AAPL"),
1312 contract("ESZ2026"),
1313 contract("ESH2027"),
1314 ];
1315 let ctx = Context::default();
1316 let mut dialog = SymbolSearchDialog::new();
1317 dialog.open();
1318 let mut frame = |events| {
1319 let mut picked = None;
1320 let output = ctx.run_ui(
1321 egui::RawInput {
1322 screen_rect: Some(egui::Rect::from_min_size(
1323 Pos2::ZERO,
1324 Vec2::new(1440.0, 900.0),
1325 )),
1326 events,
1327 ..Default::default()
1328 },
1329 |ui| picked = dialog.show(ui.ctx(), &symbols),
1330 );
1331 let texts: Vec<_> = output
1332 .shapes
1333 .iter()
1334 .filter_map(|shape| match &shape.shape {
1335 egui::Shape::Text(text) => Some((
1336 text.galley.text().to_owned(),
1337 text.pos + text.galley.size() / 2.0,
1338 )),
1339 _ => None,
1340 })
1341 .collect();
1342 output.drop_without_applying_deltas();
1343 (picked, texts)
1344 };
1345 let listed = |texts: &[(String, Pos2)]| {
1347 let mut names: Vec<_> = texts
1348 .iter()
1349 .map(|(text, _)| text.clone())
1350 .filter(|text| symbols.iter().any(|symbol| symbol.symbol == *text))
1351 .collect();
1352 names.dedup();
1353 names
1354 };
1355 let button = |pos, pressed| {
1356 vec![egui::Event::PointerButton {
1357 pos,
1358 button: egui::PointerButton::Primary,
1359 pressed,
1360 modifiers: Default::default(),
1361 }]
1362 };
1363 frame(vec![]);
1364 let (_, texts) = frame(vec![]);
1365 assert_eq!(listed(&texts), ["ES1!", "AAPL"]);
1366 let count = texts.iter().find(|(text, _)| text == "+2").unwrap().1;
1367 frame(vec![egui::Event::PointerMoved(count)]);
1368 frame(button(count, true));
1369 frame(button(count, false));
1370 let (_, texts) = frame(vec![]);
1371 assert_eq!(listed(&texts), ["ES1!", "ESZ2026", "ESH2027", "AAPL"]);
1372 let row = texts.iter().find(|(text, _)| text == "ESZ2026").unwrap().1;
1373 frame(vec![egui::Event::PointerMoved(row)]);
1374 frame(button(row, true));
1375 let (picked, _) = frame(button(row, false));
1376 assert_eq!(picked.as_ref(), Some(&symbols[2]));
1377 }
1378
1379 #[test]
1380 fn test_symbol_filtering_case_insensitive() {
1381 let symbols = create_test_symbols();
1382 for (query, expected) in [("btc", "BTCUSDT"), ("BTC", "BTCUSDT"), ("apple", "AAPL")] {
1383 let mut dialog = SymbolSearchDialog::new();
1384 dialog.search_query = query.to_string();
1385 dialog.update_filtered_symbols(&symbols);
1386 assert_eq!(dialog.filtered_symbols.len(), 1);
1387 assert_eq!(dialog.filtered_symbols[0].symbol, expected);
1388
1389 let ctx = Context::default();
1390 let mut compare = CompareSymbolsDialog::new();
1391 compare.search_query = query.to_string();
1392 let config = compare.config.resolved(&ctx.global_style().visuals);
1393 let mut output = ctx.run_ui(Default::default(), |ui| {
1394 compare.draw_content(ui, &symbols, &config);
1395 });
1396 let names: Vec<_> = output
1397 .shapes
1398 .iter()
1399 .filter_map(|shape| match &shape.shape {
1400 egui::Shape::Text(text)
1401 if symbols
1402 .iter()
1403 .any(|symbol| symbol.symbol == text.galley.text()) =>
1404 {
1405 Some(text.galley.text())
1406 }
1407 _ => None,
1408 })
1409 .collect();
1410 output.textures_delta.clear();
1411 assert_eq!(names, [expected], "compare query: {query}");
1412 output.drop_without_applying_deltas();
1413 }
1414 }
1415}