Skip to main content

egui_charts/ui/dialogs/symbol_search/
compare.rs

1//! Compare symbols dialog.
2
3use super::row::{SEARCH_RADIUS, SYMBOL_ROW_HEIGHT, paint_symbol_text, search_backdrop};
4use super::service::Search;
5use crate::icons::icons;
6use crate::localization as l10n;
7use crate::model::Instrument;
8use crate::painting::symbol_mark::paint_symbol_mark;
9use crate::services::{Storage, SymbolSearch};
10use crate::theme::ThemeExt;
11use crate::tokens::DESIGN_TOKENS;
12use crate::ui_kit::EmptyState;
13use crate::ui_kit::buttons::IconButton;
14use crate::ui_kit::ext::UiExt;
15use crate::ui_kit::modal::Modal;
16use egui::{Color32, Pos2, RichText, Sense, Ui, Vec2};
17use std::sync::Arc;
18
19// =============================================================================
20// Compare Symbols Dialog
21// =============================================================================
22
23/// Instrument information for comparison
24#[derive(Debug, Clone)]
25pub struct CompareSymbol {
26    /// Instrument ticker
27    pub symbol: String,
28    /// Full name/desc
29    pub name: String,
30    /// Exchange name
31    pub exchange: String,
32    /// Color for this symbol on chart
33    pub color: Color32,
34    /// Whether this symbol is visible on chart
35    pub visible: bool,
36}
37
38impl CompareSymbol {
39    /// Create a new compare symbol
40    pub fn new(
41        symbol: impl Into<String>,
42        name: impl Into<String>,
43        exchange: impl Into<String>,
44        color: Color32,
45    ) -> Self {
46        Self {
47            symbol: symbol.into(),
48            name: name.into(),
49            exchange: exchange.into(),
50            color,
51            visible: true,
52        }
53    }
54}
55
56/// Configuration for compare symbols dialog
57#[derive(Debug, Clone)]
58pub struct CompareSymbolsConfig {
59    /// Dialog width
60    pub width: f32,
61    /// Dialog height
62    pub height: f32,
63    /// Background color
64    pub bg_color: Color32,
65    /// Text color
66    pub text_color: Color32,
67    /// Muted/secondary text color
68    pub muted_color: Color32,
69    /// Hover background color
70    pub hover_color: Color32,
71}
72
73impl Default for CompareSymbolsConfig {
74    fn default() -> Self {
75        // Colors are TRANSPARENT to signal that colors should be fetched from
76        // ui.style().visuals at render time for proper theme support.
77        Self {
78            width: 840.0,
79            height: 560.0,
80            bg_color: Color32::TRANSPARENT,
81            text_color: Color32::TRANSPARENT,
82            muted_color: Color32::TRANSPARENT,
83            hover_color: Color32::TRANSPARENT,
84        }
85    }
86}
87
88impl CompareSymbolsConfig {
89    /// The configured colors, each `TRANSPARENT` one taken from `visuals`.
90    pub(super) fn resolved(&self, visuals: &egui::Visuals) -> Self {
91        let or = |color: Color32, theme: Color32| {
92            if color == Color32::TRANSPARENT {
93                theme
94            } else {
95                color
96            }
97        };
98        Self {
99            bg_color: or(self.bg_color, visuals.panel_fill),
100            text_color: or(self.text_color, visuals.text_color()),
101            muted_color: or(self.muted_color, visuals.weak_text_color()),
102            hover_color: or(self.hover_color, visuals.widgets.hovered.weak_bg_fill),
103            ..self.clone()
104        }
105    }
106}
107
108/// Where a symbol added from the compare dialog goes.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
110#[non_exhaustive]
111pub enum ComparePlacement {
112    /// On the main price scale, which then shows every series as a percentage change.
113    #[default]
114    SamePercentScale,
115    /// On a price scale of its own, beside the main one.
116    NewPriceScale,
117    /// In a pane of its own.
118    NewPane,
119}
120
121impl ComparePlacement {
122    /// Every placement, in the order the dialog offers them.
123    const ALL: [Self; 3] = [Self::SamePercentScale, Self::NewPriceScale, Self::NewPane];
124
125    /// The label of the placement's button.
126    fn label(self) -> &'static str {
127        match self {
128            Self::SamePercentScale => "Same % scale",
129            Self::NewPriceScale => "New price scale",
130            Self::NewPane => "New pane",
131        }
132    }
133}
134
135/// Action from compare symbols dialog
136#[derive(Debug, Clone, PartialEq)]
137#[non_exhaustive]
138pub enum CompareAction {
139    /// No action
140    None,
141    /// Add the symbol to the comparison, placed as the user chose. A click on the row itself
142    /// chooses [`ComparePlacement::SamePercentScale`].
143    AddSymbol(String, ComparePlacement),
144    /// Remove symbol from comparison
145    RemoveSymbol(String),
146    /// Toggle symbol visibility
147    ToggleVisibility(String),
148    /// Dialog closed
149    Close,
150}
151
152/// Compare symbols dialog
153///
154/// Allows comparing multiple symbols on the same chart with:
155/// - Search for symbols to add
156/// - Suggestions from the symbols the host passes in, with an empty state when there are none
157/// - Color-coded symbols
158/// - Visibility toggles
159///
160/// # Example
161///
162/// ```no_run
163/// use egui_charts::model::Instrument;
164/// use egui_charts::ui::top_toolbar::{CompareAction, CompareSymbolsDialog};
165///
166/// # fn update(ctx: &egui::Context) {
167/// let mut compare = CompareSymbolsDialog::new();
168/// compare.open();
169///
170/// let symbols = vec![Instrument::new("ETHUSDT").with_description("Ethereum / Tether")];
171/// match compare.show(ctx, &symbols) {
172///     CompareAction::AddSymbol(sym, placement) => {
173///         // Add the symbol to the chart where `placement` says
174///     }
175///     CompareAction::RemoveSymbol(sym) => {
176///         // Remove symbol from chart
177///     }
178///     _ => {}
179/// }
180/// # }
181/// ```
182pub struct CompareSymbolsDialog {
183    /// Is dialog open
184    pub is_open: bool,
185    /// Search query
186    pub(super) search_query: String,
187    /// Current comparison series on the host's chart. Update after accepting an intent,
188    /// including changes made outside this dialog.
189    pub compared_symbols: Vec<CompareSymbol>,
190    /// Configuration
191    pub(super) config: CompareSymbolsConfig,
192    search: Search,
193}
194
195impl Default for CompareSymbolsDialog {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201impl CompareSymbolsDialog {
202    /// Create a new compare symbols dialog
203    pub fn new() -> Self {
204        Self {
205            is_open: false,
206            search_query: String::new(),
207            compared_symbols: Vec::new(),
208            config: CompareSymbolsConfig::default(),
209            search: Search::default(),
210        }
211    }
212
213    /// Connects the same host service used by symbol search.
214    pub fn set_search_service(&mut self, service: Option<Arc<dyn SymbolSearch>>) {
215        self.search.set_service(service);
216    }
217
218    /// Lists the instruments symbol search recently opened, from the same host storage, while
219    /// the query is empty.
220    pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
221        self.search.recents.set_storage(storage);
222    }
223
224    /// Create with custom configuration
225    pub fn with_config(mut self, config: CompareSymbolsConfig) -> Self {
226        self.config = config;
227        self
228    }
229
230    /// Open the dialog
231    pub fn open(&mut self) {
232        self.is_open = true;
233        self.search_query.clear();
234        self.search.reset();
235        // Symbol search writes the recent list through its own store.
236        self.search.recents.reload();
237    }
238
239    /// Close the dialog
240    pub fn close(&mut self) {
241        self.is_open = false;
242    }
243
244    /// Show the dialog, suggesting `symbols`: the symbols the host's data source offers
245    pub fn show(&mut self, ctx: &egui::Context, symbols: &[Instrument]) -> CompareAction {
246        let mut action = CompareAction::None;
247        self.search.poll(ctx);
248
249        if !self.is_open {
250            return action;
251        }
252
253        let mut is_open = self.is_open;
254        let config = self.config.resolved(&ctx.global_style().visuals);
255        Modal::new("compare_symbols", "")
256            .width(config.width)
257            .height(config.height)
258            .fill(config.bg_color)
259            .rounding(SEARCH_RADIUS)
260            .backdrop(search_backdrop(ctx))
261            .show(ctx, &mut is_open, |ui| {
262                let translated_label = l10n::text(ui.ctx(), "Compare symbols");
263                ui.response().widget_info(|| {
264                    egui::WidgetInfo::labeled(egui::Role::Dialog, true, &translated_label)
265                });
266                ui.ctx()
267                    .accesskit_node_builder(ui.response().id, |node| node.set_modal());
268                action = self.draw_content(ui, symbols, &config);
269            });
270
271        self.is_open = is_open && action != CompareAction::Close;
272        if !self.is_open {
273            action = CompareAction::Close;
274        }
275
276        action
277    }
278
279    pub(super) fn draw_content(
280        &mut self,
281        ui: &mut Ui,
282        symbols: &[Instrument],
283        config: &CompareSymbolsConfig,
284    ) -> CompareAction {
285        let mut action = CompareAction::None;
286
287        ui.horizontal(|ui| {
288            ui.add(icons::QUICK_SEARCH.as_image_tinted(Vec2::splat(24.0), config.muted_color));
289            ui.add(
290                egui::TextEdit::singleline(&mut self.search_query)
291                    .hint_text(
292                        RichText::new(l10n::text(ui.ctx(), "Search symbols"))
293                            .size(16.0)
294                            .color(ui.active_theme().ui.text_disabled),
295                    )
296                    .font(egui::FontId::proportional(16.0))
297                    .frame(egui::Frame::NONE)
298                    .desired_width((ui.available_width() - 40.0).max(0.0)),
299            )
300            .accessible_name(l10n::text(ui.ctx(), "Search symbols"));
301            if IconButton::new(&icons::CLOSE)
302                .tooltip("Close")
303                .show(ui)
304                .clicked()
305            {
306                action = CompareAction::Close;
307            }
308        });
309        ui.add_space(24.0);
310
311        // Currently compared symbols
312        if !self.compared_symbols.is_empty() {
313            ui.label(
314                RichText::new(l10n::text(ui.ctx(), "Currently comparing:"))
315                    .color(config.muted_color),
316            );
317            ui.space_sm();
318
319            egui::ScrollArea::vertical()
320                .id_salt("current_comparisons")
321                .max_height(ui.available_height() * 0.4)
322                .show(ui, |ui| {
323                    for symbol in &self.compared_symbols {
324                        ui.horizontal(|ui| {
325                            // Color indicator
326                            let (rect, _) = ui.allocate_exact_size(
327                                Vec2::splat(DESIGN_TOKENS.sizing.icon_xs),
328                                Sense::hover(),
329                            );
330                            ui.painter().circle_filled(rect.center(), 5.0, symbol.color);
331
332                            ui.label(l10n::text(ui.ctx(), &symbol.symbol));
333                            ui.label(
334                                RichText::new(&symbol.name)
335                                    .color(config.muted_color)
336                                    .size(ui.active_theme().text.small),
337                            );
338
339                            ui.right_aligned(|ui| {
340                                if ui.small_button(l10n::text(ui.ctx(), "X")).clicked() {
341                                    action = CompareAction::RemoveSymbol(symbol.symbol.clone());
342                                }
343
344                                let eye_label = if symbol.visible { "Hide" } else { "Show" };
345                                if ui.small_button(l10n::text(ui.ctx(), eye_label)).clicked() {
346                                    action = CompareAction::ToggleVisibility(symbol.symbol.clone());
347                                }
348                            });
349                        });
350                    }
351                });
352            ui.separator_with_margin(ui.active_theme().spacing.lg);
353        }
354
355        self.search.query(ui.ctx(), &self.search_query, None, 20);
356        let recent = self.search_query.is_empty() && self.search.has_recents();
357        let found = if recent {
358            self.search.recent_results()
359        } else {
360            self.search.results()
361        };
362        let symbols = if self.search.connected() || recent {
363            found.as_slice()
364        } else {
365            symbols
366        };
367        self.search.status(ui);
368        let mut next_page = false;
369        // Suggestions
370        let heading = if recent { "Recent symbols" } else { "Symbols" };
371        ui.label(
372            RichText::new(l10n::text(ui.ctx(), heading).to_uppercase())
373                .size(11.0)
374                .color(config.muted_color),
375        );
376        ui.space_lg();
377
378        if symbols.is_empty() {
379            EmptyState::new("No symbols to compare").show(ui);
380        }
381
382        egui::ScrollArea::vertical()
383            .id_salt("comparison_suggestions")
384            .max_height(ui.available_height())
385            .show(ui, |ui| {
386                ui.spacing_mut().item_spacing.y = 0.0;
387                let query = self.search_query.to_lowercase();
388                for symbol in symbols.iter().filter(|symbol| {
389                    self.search.connected()
390                        || symbol.symbol.to_lowercase().contains(&query)
391                        || symbol
392                            .description
393                            .as_deref()
394                            .unwrap_or(&symbol.symbol)
395                            .to_lowercase()
396                            .contains(&query)
397                }) {
398                    next_page |= ui.clip_rect().contains(ui.cursor().min)
399                        && symbols
400                            .last()
401                            .is_some_and(|last| last.symbol == symbol.symbol);
402                    if let Some(a) = self.draw_symbol_item(ui, symbol, config) {
403                        action = a;
404                    }
405                }
406            });
407
408        if next_page {
409            self.search.next_page();
410        }
411        action
412    }
413
414    fn draw_symbol_item(
415        &self,
416        ui: &mut Ui,
417        symbol: &Instrument,
418        config: &CompareSymbolsConfig,
419    ) -> Option<CompareAction> {
420        let theme = ui.active_theme();
421        let is_compared = self
422            .compared_symbols
423            .iter()
424            .any(|s| s.symbol == symbol.symbol);
425
426        let desired_size = Vec2::new(ui.available_width(), SYMBOL_ROW_HEIGHT);
427        let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
428        response.widget_info(|| {
429            egui::WidgetInfo::labeled(
430                egui::Role::ListBoxOption,
431                response.enabled() && !is_compared,
432                &symbol.symbol,
433            )
434        });
435        ui.ctx()
436            .accesskit_node_builder(response.id, |node| node.set_selected(is_compared));
437        crate::styles::focus::draw_focus_ring(ui, &response, theme.radius.md);
438
439        // The placement buttons follow the pointer or the keyboard focus into the row.
440        let placement_ids = ComparePlacement::ALL.map(|placement| response.id.with(placement));
441        let menu_id = response.id.with("placements");
442        let engaged = response.contains_pointer()
443            || response.has_focus()
444            || egui::Popup::is_id_open(ui.ctx(), menu_id.with("popup"))
445            || ui.memory(|m| m.has_focus(menu_id))
446            || ui.memory(|m| placement_ids.iter().any(|id| m.has_focus(*id)));
447        if engaged && !is_compared {
448            ui.painter()
449                .rect_filled(rect, theme.radius.md, config.hover_color);
450        }
451
452        // Every row reserves the mark, so text lines up whatever the host supplies.
453        let mark = egui::Rect::from_center_size(
454            Pos2::new(rect.min.x + 4.0 + 18.0, rect.center().y),
455            Vec2::splat(36.0),
456        );
457        paint_symbol_mark(
458            ui.painter(),
459            mark,
460            &symbol.symbol,
461            symbol.logo.as_deref().map(egui::ImageSource::from),
462        );
463        let left = mark.right() + 8.0;
464
465        // Right-hand column, from the edge: country, then the comparison check.
466        let mut right = rect.max.x - 12.0;
467        if let Some(country) = &symbol.country {
468            let galley = ui.painter().layout_no_wrap(
469                country.to_uppercase(),
470                egui::FontId::proportional(theme.text.small),
471                config.muted_color,
472            );
473            let pos = Pos2::new(
474                right - galley.size().x,
475                rect.center().y - galley.size().y / 2.0,
476            );
477            right = pos.x - 12.0;
478            ui.painter().galley(pos, galley, config.muted_color);
479        }
480        if is_compared {
481            let center = Pos2::new(right - 8.0, rect.center().y);
482            ui.painter().circle_filled(center, 8.0, theme.ui.success);
483            ui.painter().text(
484                center,
485                egui::Align2::CENTER_CENTER,
486                l10n::text(ui.ctx(), "v"),
487                egui::FontId::proportional(theme.text.micro),
488                theme.ui.panel_bg,
489            );
490            right = center.x - 20.0;
491        }
492
493        let mut action = None;
494        if engaged && !is_compared {
495            let placements = ComparePlacement::ALL.map(|placement| {
496                let label = ui.painter().layout_no_wrap(
497                    l10n::text(ui.ctx(), placement.label()),
498                    egui::FontId::proportional(theme.text.small),
499                    config.text_color,
500                );
501                (placement, label)
502            });
503            let buttons_width: f32 = placements
504                .iter()
505                .map(|(_, label)| label.size().x + 20.0)
506                .sum();
507            if right - left < buttons_width + 80.0 {
508                let button = egui::Rect::from_center_size(
509                    Pos2::new(right - 20.0, rect.center().y),
510                    Vec2::new(40.0, 24.0),
511                );
512                right = button.left() - 4.0;
513                let menu = ui.interact(button, menu_id, Sense::click());
514                let label = l10n::text(ui.ctx(), "Add");
515                menu.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
516                let visuals = ui.style().interact(&menu);
517                ui.painter().rect(
518                    button,
519                    12.0,
520                    visuals.weak_bg_fill,
521                    visuals.bg_stroke,
522                    egui::StrokeKind::Inside,
523                );
524                ui.painter().text(
525                    button.center(),
526                    egui::Align2::CENTER_CENTER,
527                    label,
528                    egui::FontId::proportional(theme.text.small),
529                    config.text_color,
530                );
531                crate::styles::focus::draw_focus_ring(ui, &menu, 12.0);
532                egui::Popup::menu(&menu).show(|ui| {
533                    for placement in ComparePlacement::ALL {
534                        if ui.button(l10n::text(ui.ctx(), placement.label())).clicked() {
535                            action =
536                                Some(CompareAction::AddSymbol(symbol.symbol.clone(), placement));
537                            ui.close();
538                        }
539                    }
540                });
541            } else {
542                for ((placement, galley), id) in placements.into_iter().zip(placement_ids).rev() {
543                    let button = egui::Rect::from_min_max(
544                        Pos2::new(right - galley.size().x - 16.0, rect.center().y - 12.0),
545                        Pos2::new(right, rect.center().y + 12.0),
546                    );
547                    right = button.left() - 4.0;
548                    let clicked = ui.interact(button, id, Sense::click());
549                    clicked.widget_info(|| {
550                        egui::WidgetInfo::labeled(egui::Role::Button, true, galley.text())
551                    });
552                    let fill = if clicked.hovered() {
553                        theme.ui.btn_bg_hover
554                    } else {
555                        config.bg_color
556                    };
557                    ui.painter().rect(
558                        button,
559                        button.height() / 2.0,
560                        fill,
561                        ui.visuals().widgets.inactive.bg_stroke,
562                        egui::StrokeKind::Inside,
563                    );
564                    crate::styles::focus::draw_focus_ring(ui, &clicked, button.height() / 2.0);
565                    ui.painter().galley(
566                        button.center() - galley.size() / 2.0,
567                        galley,
568                        config.text_color,
569                    );
570                    if clicked.clicked() {
571                        action = Some(CompareAction::AddSymbol(symbol.symbol.clone(), placement));
572                    }
573                }
574            }
575        }
576
577        paint_symbol_text(
578            ui,
579            symbol,
580            egui::Rect::from_min_max(
581                Pos2::new(left, rect.top()),
582                Pos2::new((right - 12.0).max(left), rect.bottom()),
583            ),
584            &self.search_query,
585            [config.text_color, config.muted_color],
586            true,
587            true,
588        );
589
590        if action.is_none() && response.clicked() && !is_compared {
591            action = Some(CompareAction::AddSymbol(
592                symbol.symbol.clone(),
593                ComparePlacement::SamePercentScale,
594            ));
595        }
596        action
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crate::ui::dialogs::symbol_search::tests::create_test_symbols;
604    use egui::Context;
605
606    #[test]
607    fn compare_recents_do_not_require_a_search_service() {
608        let symbols = create_test_symbols();
609        for connected in [false, true] {
610            let ctx = Context::default();
611            let mut dialog = CompareSymbolsDialog::new();
612            if connected {
613                dialog.set_search_service(Some(Arc::new(symbols.clone())));
614            }
615            dialog.search.record(&symbols[0]);
616            dialog.search.poll(&ctx);
617            let config = dialog.config.resolved(&ctx.global_style().visuals);
618            for (query, heading, listed, absent) in [
619                ("", "RECENT SYMBOLS", "BTCUSDT", "TSLA"),
620                ("tsla", "SYMBOLS", "TSLA", "BTCUSDT"),
621            ] {
622                dialog.search_query = query.into();
623                for frame in 0..2 {
624                    let output = ctx.run_ui(Default::default(), |ui| {
625                        dialog.draw_content(ui, &symbols, &config);
626                    });
627                    let texts: Vec<_> = output
628                        .shapes
629                        .iter()
630                        .filter_map(|shape| match &shape.shape {
631                            egui::Shape::Text(text) => Some(text.galley.text()),
632                            _ => None,
633                        })
634                        .collect();
635                    if frame > 0 {
636                        assert!(texts.contains(&heading), "connected {connected}: {texts:?}");
637                        assert!(texts.contains(&listed));
638                        assert!(!texts.contains(&absent));
639                    }
640                    output.drop_without_applying_deltas();
641                }
642            }
643        }
644    }
645
646    #[test]
647    fn comparison_actions_wait_for_host_acceptance() {
648        let mut dialog = CompareSymbolsDialog::new();
649        assert!(!dialog.is_open);
650
651        dialog.open();
652        assert!(dialog.is_open);
653
654        dialog.close();
655        assert!(!dialog.is_open);
656        let symbols = create_test_symbols();
657        let add = |placement| CompareAction::AddSymbol("AAPL".into(), placement);
658        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
659            for width in [1440.0, 360.0] {
660                for (hover, label, expected, populated) in [
661                    (None, "AAPL", add(ComparePlacement::SamePercentScale), false),
662                    (
663                        Some("AAPL"),
664                        "Same % scale",
665                        add(ComparePlacement::SamePercentScale),
666                        false,
667                    ),
668                    (
669                        Some("AAPL"),
670                        "New price scale",
671                        add(ComparePlacement::NewPriceScale),
672                        false,
673                    ),
674                    (
675                        Some("AAPL"),
676                        "New pane",
677                        add(ComparePlacement::NewPane),
678                        false,
679                    ),
680                    (None, "X", CompareAction::RemoveSymbol("AAPL".into()), true),
681                    (
682                        None,
683                        "Hide",
684                        CompareAction::ToggleVisibility("AAPL".into()),
685                        true,
686                    ),
687                ] {
688                    let ctx = Context::default();
689                    crate::theme::apply_to_egui(&ctx, &theme);
690                    let mut dialog = CompareSymbolsDialog::new();
691                    if populated {
692                        dialog.compared_symbols.push(CompareSymbol::new(
693                            "AAPL",
694                            "Apple",
695                            "Test",
696                            Color32::GOLD,
697                        ));
698                    }
699                    let config = dialog.config.resolved(&ctx.global_style().visuals);
700                    let mut frame = |events, label| {
701                        let mut action = CompareAction::None;
702                        let output = ctx.run_ui(
703                            egui::RawInput {
704                                screen_rect: Some(egui::Rect::from_min_size(
705                                    Pos2::ZERO,
706                                    Vec2::new(width, 640.0),
707                                )),
708                                events,
709                                ..Default::default()
710                            },
711                            |ui| {
712                                action = dialog.draw_content(ui, &symbols, &config);
713                            },
714                        );
715                        let pos = output.shapes.iter().find_map(|shape| match &shape.shape {
716                            egui::Shape::Text(text) if text.galley.text() == label => {
717                                let rect = egui::Rect::from_min_size(text.pos, text.galley.size());
718                                assert!(!text.galley.elided, "{label} is unreadable at {width}");
719                                assert!(
720                                    shape.clip_rect.contains_rect(rect),
721                                    "{label} is clipped at {width}"
722                                );
723                                Some(rect.center())
724                            }
725                            _ => None,
726                        });
727                        output.drop_without_applying_deltas();
728                        (action, pos)
729                    };
730                    frame(vec![], label);
731                    if let Some(row) = hover {
732                        let pos = frame(vec![], row).1.unwrap();
733                        frame(vec![egui::Event::PointerMoved(pos)], row);
734                    }
735                    if hover.is_some()
736                        && let Some(pos) = frame(vec![], "Add").1
737                    {
738                        frame(vec![egui::Event::PointerMoved(pos)], "Add");
739                        for pressed in [true, false] {
740                            frame(
741                                vec![egui::Event::PointerButton {
742                                    pos,
743                                    button: egui::PointerButton::Primary,
744                                    pressed,
745                                    modifiers: Default::default(),
746                                }],
747                                "Add",
748                            );
749                        }
750                    }
751                    let pos = frame(vec![], label).1.unwrap();
752                    frame(vec![egui::Event::PointerMoved(pos)], label);
753                    let button = |pressed| egui::Event::PointerButton {
754                        pos,
755                        button: egui::PointerButton::Primary,
756                        pressed,
757                        modifiers: Default::default(),
758                    };
759                    frame(vec![button(true)], label);
760                    assert_eq!(frame(vec![button(false)], label).0, expected);
761                    assert_eq!(
762                        dialog.compared_symbols.len(),
763                        usize::from(populated),
764                        "only the host updates chart membership"
765                    );
766                    if populated {
767                        assert!(
768                            dialog.compared_symbols[0].visible,
769                            "only the host updates chart visibility"
770                        );
771                    }
772                }
773            }
774        }
775    }
776}