1use 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#[derive(Debug, Clone)]
25pub struct CompareSymbol {
26 pub symbol: String,
28 pub name: String,
30 pub exchange: String,
32 pub color: Color32,
34 pub visible: bool,
36}
37
38impl CompareSymbol {
39 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#[derive(Debug, Clone)]
58pub struct CompareSymbolsConfig {
59 pub width: f32,
61 pub height: f32,
63 pub bg_color: Color32,
65 pub text_color: Color32,
67 pub muted_color: Color32,
69 pub hover_color: Color32,
71}
72
73impl Default for CompareSymbolsConfig {
74 fn default() -> Self {
75 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
110#[non_exhaustive]
111pub enum ComparePlacement {
112 #[default]
114 SamePercentScale,
115 NewPriceScale,
117 NewPane,
119}
120
121impl ComparePlacement {
122 const ALL: [Self; 3] = [Self::SamePercentScale, Self::NewPriceScale, Self::NewPane];
124
125 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#[derive(Debug, Clone, PartialEq)]
137#[non_exhaustive]
138pub enum CompareAction {
139 None,
141 AddSymbol(String, ComparePlacement),
144 RemoveSymbol(String),
146 ToggleVisibility(String),
148 Close,
150}
151
152pub struct CompareSymbolsDialog {
183 pub is_open: bool,
185 pub(super) search_query: String,
187 pub compared_symbols: Vec<CompareSymbol>,
190 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 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 pub fn set_search_service(&mut self, service: Option<Arc<dyn SymbolSearch>>) {
215 self.search.set_service(service);
216 }
217
218 pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
221 self.search.recents.set_storage(storage);
222 }
223
224 pub fn with_config(mut self, config: CompareSymbolsConfig) -> Self {
226 self.config = config;
227 self
228 }
229
230 pub fn open(&mut self) {
232 self.is_open = true;
233 self.search_query.clear();
234 self.search.reset();
235 self.search.recents.reload();
237 }
238
239 pub fn close(&mut self) {
241 self.is_open = false;
242 }
243
244 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 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 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 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 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 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 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}