Skip to main content

egui_charts/ui/dialogs/symbol_search/
mod.rs

1//! Instrument search and comparison dialogs
2//!
3//! Provides symbol search with keyboard navigation
4//! and multi-symbol comparison functionality.
5//!
6//! # Example - Instrument Search
7//!
8//! ```no_run
9//! use egui_charts::model::Instrument;
10//! use egui_charts::ui::top_toolbar::SymbolSearchDialog;
11//!
12//! # fn update(ctx: &egui::Context) {
13//! let mut dialog = SymbolSearchDialog::new();
14//! let symbols = vec![
15//!     Instrument::new("BTCUSDT").with_description("Bitcoin / Tether"),
16//!     Instrument::new("ETHUSDT").with_description("Ethereum / Tether"),
17//! ];
18//!
19//! // In your egui update loop
20//! if let Some(selected) = dialog.show(ctx, &symbols) {
21//!     println!("Selected: {}", selected.symbol);
22//! }
23//! # }
24//! ```
25//!
26//! # Example - Compare Symbols
27//!
28//! ```no_run
29//! use egui_charts::model::Instrument;
30//! use egui_charts::ui::top_toolbar::{CompareAction, CompareSymbolsDialog};
31//!
32//! # fn update(ctx: &egui::Context) {
33//! let mut compare = CompareSymbolsDialog::new();
34//! compare.open();
35//!
36//! // The symbols your data source offers
37//! let symbols = vec![Instrument::new("ETHUSDT").with_description("Ethereum / Tether")];
38//! match compare.show(ctx, &symbols) {
39//!     CompareAction::AddSymbol(sym, placement) => println!("Add: {sym} ({placement:?})"),
40//!     CompareAction::RemoveSymbol(sym) => println!("Remove: {}", sym),
41//!     _ => {}
42//! }
43//! # }
44//! ```
45
46use 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// =============================================================================
68// Instrument Search Dialog
69// =============================================================================
70
71#[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/// Configuration for symbol search dialog
99///
100/// Customize the behavior and appearance of the symbol search.
101///
102/// # Example
103///
104/// ```
105/// use egui_charts::ui::top_toolbar::SymbolSearchConfig;
106///
107/// let config = SymbolSearchConfig {
108///     show_exchange: true,
109///     show_description: true,
110///     max_results: 50,
111///     min_search_len: 2,
112/// };
113/// ```
114#[derive(Debug, Clone)]
115pub struct SymbolSearchConfig {
116    /// Show exchange/desc information below symbol name
117    pub show_exchange: bool,
118    /// Show full symbol desc
119    pub show_description: bool,
120    /// Results per service page, or the maximum displayed from the supplied list
121    pub max_results: usize,
122    /// Min characters for a nonempty query; an empty query lists the catalog
123    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
137/// Instrument search dialog with keyboard navigation
138///
139/// A modal dialog for searching and selecting symbols. Features:
140/// - Real-time filtering as you type
141/// - Keyboard navigation (Up/Down to move, Enter to select, Esc to close)
142/// - Case-insensitive search
143/// - Searches both symbol names and descriptions
144///
145/// # Example
146///
147/// ```no_run
148/// use egui_charts::model::Instrument;
149/// use egui_charts::ui::top_toolbar::{SymbolSearchConfig, SymbolSearchDialog};
150///
151/// # fn update(ctx: &egui::Context) {
152/// let mut dialog = SymbolSearchDialog::new()
153///     .with_config(SymbolSearchConfig {
154///         max_results: 20,
155///         ..Default::default()
156///     });
157///
158/// // Open the dialog (e.g., on Ctrl+K)
159/// dialog.open();
160///
161/// // In your egui update loop
162/// let symbols = vec![Instrument::new("BTCUSDT").with_description("Bitcoin")];
163/// if let Some(symbol) = dialog.show(ctx, &symbols) {
164///     // User selected a symbol
165///     println!("Switching to: {}", symbol.symbol);
166/// }
167/// # }
168/// ```
169pub 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    /// Exchanges and symbol classes the feed reports, which replace those of the symbols shown.
180    feed_exchanges: Option<Vec<String>>,
181    feed_symbol_types: Option<Vec<SymbolType>>,
182    expanded: Vec<String>,
183    /// Why the query's expression is not listed: still being checked, or invalid.
184    expression_status: Option<(String, bool)>,
185    config: SymbolSearchConfig,
186    search: Search,
187}
188
189/// A listed result: its index in the results and, for the first instrument of a root, how many
190/// others share that root.
191#[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    /// Creates a new symbol search dialog with default configuration
206    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    /// Sets a custom configuration for the dialog
227    ///
228    /// # Example
229    ///
230    /// ```
231    /// use egui_charts::ui::top_toolbar::{SymbolSearchConfig, SymbolSearchDialog};
232    ///
233    /// let dialog = SymbolSearchDialog::new()
234    ///     .with_config(SymbolSearchConfig {
235    ///         max_results: 50,
236    ///         min_search_len: 2,
237    ///         ..Default::default()
238    ///     });
239    /// ```
240    pub fn with_config(mut self, config: SymbolSearchConfig) -> Self {
241        self.config = config;
242        self
243    }
244
245    /// Connects host symbol search. Without a service, `show` searches its supplied list.
246    pub fn set_search_service(&mut self, service: Option<Arc<dyn SymbolSearch>>) {
247        self.search.set_service(service);
248    }
249
250    /// Keeps recently selected instruments through the host's storage service.
251    pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
252        self.search.recents.set_storage(storage);
253    }
254
255    /// Restricts results to an exchange, or removes that filter, as the source menu does.
256    pub fn set_exchange_filter(&mut self, exchange: Option<String>) {
257        self.search.exchange = exchange;
258    }
259
260    /// Offers these exchanges in the source menu, whose choice reaches the service as
261    /// [`SymbolQuery::exchange`](crate::services::SymbolQuery::exchange). Without a service the
262    /// menu offers the exchanges of the supplied symbols.
263    pub fn set_sources(&mut self, sources: Vec<String>) {
264        self.sources = sources;
265    }
266
267    /// Takes the exchanges and symbol classes the feed serves, known before any symbol resolves:
268    /// pass [`Chart::feed_capabilities`](crate::widget::Chart::feed_capabilities). Reported
269    /// exchanges are the source menu's choices, replacing those of the supplied symbols and
270    /// [`set_sources`](Self::set_sources); an empty list offers none. Reported classes are the
271    /// category tabs. A list the feed does not report leaves the choices as they were.
272    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    /// Records an instrument opened outside this dialog in the recent list.
278    pub fn record_symbol(&mut self, symbol: &Instrument) {
279        self.search.record(symbol);
280    }
281
282    /// Supply recent symbol names, newest first. Unknown names are ignored.
283    pub fn set_recent_symbols(&mut self, symbols: Vec<String>) {
284        self.recent_symbols = symbols;
285    }
286
287    /// Opens the symbol search dialog
288    ///
289    /// Clears any previous search state and shows the dialog.
290    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    /// Open symbol search with text supplied by the host's keyboard dispatcher.
303    pub fn open_with_query(&mut self, query: String) {
304        self.open();
305        self.search_query = query;
306    }
307
308    /// Opens symbol search with the supplied text selected on its first focused frame, so
309    /// typing replaces it. Hosts pass the current symbol when the user opens search from a
310    /// toolbar or an action.
311    pub fn open_with_selected_query(&mut self, query: String) {
312        self.open_with_query(query);
313        self.select_query = true;
314    }
315
316    /// Closes the symbol search dialog
317    pub fn close(&mut self) {
318        self.is_open = false;
319    }
320
321    /// Returns whether the dialog is currently open
322    pub fn is_open(&self) -> bool {
323        self.is_open
324    }
325
326    /// Shows the symbol search dialog and returns the selected symbol
327    ///
328    /// Displays a modal search dialog with real-time filtering and keyboard navigation.
329    ///
330    /// # Arguments
331    ///
332    /// * `ctx` - The egui context
333    /// * `all_symbols` - List of all available symbols to search from
334    ///
335    /// # Returns
336    ///
337    /// * `Some(Instrument)` - The symbol selected by the user
338    /// * `None` - Dialog closed without selection or still searching
339    ///
340    /// # Keyboard Shortcuts
341    ///
342    /// - **Up/Down**: Navigate results
343    /// - **Enter**: Select highlighted symbol
344    /// - **Esc**: Close dialog
345    ///
346    /// # Example
347    ///
348    /// ```no_run
349    /// # use egui_charts::model::Instrument;
350    /// # use egui_charts::ui::top_toolbar::SymbolSearchDialog;
351    /// # fn update(ctx: &egui::Context, dialog: &mut SymbolSearchDialog) {
352    /// let symbols = vec![
353    ///     Instrument::new("BTCUSDT").with_description("Bitcoin / Tether"),
354    ///     Instrument::new("ETHUSDT").with_description("Ethereum / Tether"),
355    /// ];
356    ///
357    /// if let Some(symbol) = dialog.show(ctx, &symbols) {
358    ///     println!("User selected: {}", symbol.symbol);
359    /// }
360    /// # }
361    /// ```
362    #[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                    // Sizing passes clear invisible widgets' focus while building the field.
453                    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                // Results list
583                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                // Keyboard navigation
674                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    /// Lists first the spread the query spells, such as `AAPL-MSFT`, once its symbols are found
721    /// in the catalog, or says why it cannot be charted.
722    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    /// The listed results: each instrument without a root, the first of each root, and the other
796    /// instruments of an expanded root right after their first.
797    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// =============================================================================
836// Tests
837// =============================================================================
838
839#[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            // A typed shortcut query keeps the next keystroke after it.
1108            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            // The clear control sits immediately before the close control in the query row.
1115            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    /// An expression over listed symbols comes first, spelled as the catalog spells them;
1265    /// unknown symbols and malformed text are reported instead.
1266    #[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    /// A root lists its first instrument with the count of the others; a click on the count lists
1300    /// them under it, and a chosen contract comes back exactly, exchange included.
1301    #[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        // Each row also shows its symbol as the description it lacks.
1346        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}