Skip to main content

egui_charts/ui/dialogs/indicator_dialog/
dialog.rs

1//! Study browser dialog backed by the configured factory.
2//!
3//! Use [`IndicatorDialog`] for search, category and favorite filtering and add-study
4//! actions. Host-provided metric series can be offered beside the factory catalogue.
5
6use super::{
7    actions::IndicatorDialogAction,
8    config::IndicatorDialogConfig,
9    data::{IndicatorInfo, IndicatorPreferences},
10    types::{IndicatorCategory, IndicatorTab},
11};
12use crate::data::inbox::Inbox;
13use crate::data::{Datafeed, FeedCapabilities, FeedError, MetricSeries};
14use crate::icons::icons;
15use crate::localization as l10n;
16use crate::model::Instrument;
17use crate::studies::*;
18use crate::styles::focus::draw_focus_ring;
19use crate::theme::Theme;
20use crate::theme::ThemeExt;
21use crate::tokens::DESIGN_TOKENS;
22use crate::ui_kit::EmptyState;
23use crate::ui_kit::filter::FilterWidget;
24use crate::ui_kit::modal::Modal;
25use crate::ui_kit::sidebar_layout::{SidebarLayout, SidebarTab};
26use crate::ui_kit::tab_bar::TabBar;
27use egui::text::{LayoutJob, TextWrapping};
28use egui::{
29    Align2, Color32, FontId, Key, Modifiers, Pos2, Rect, Response, RichText, Role, Sense, Stroke,
30    StrokeKind, Ui, Vec2, WidgetInfo,
31};
32use std::cell::Cell;
33use std::collections::HashSet;
34use std::sync::Arc;
35
36/// Width of the category sidebar.
37const SIDEBAR_WIDTH: f32 = 200.0;
38
39/// Indicator Dialog
40///
41/// Modal dialog for searching and adding indicators. It lists studies from its
42/// [`IndicatorFactory`], filtered by the supplied source and feed capabilities; a click or
43/// Enter on a row adds that study with its default inputs and leaves the dialog open for the
44/// next one. Edit the inputs of an added study with
45/// [`IndicatorSettingsDialog`](super::IndicatorSettingsDialog).
46pub struct IndicatorDialog {
47    /// Is dialog open
48    pub is_open: bool,
49    /// Search field and query
50    search: FilterWidget,
51    /// Focus the search field on the next frame
52    focus_search: bool,
53    /// Move the focus from the search field to the first row this frame
54    focus_first_row: bool,
55    /// Active tab
56    active_tab: IndicatorTab,
57    /// Selected category
58    sel_category: IndicatorCategory,
59    /// Favorite indicator IDs
60    pub favorites: HashSet<String>,
61    /// Creates the studies the dialog lists
62    factory: IndicatorFactory,
63    feed_capabilities: Option<(Option<FeedCapabilities>, Option<Instrument>)>,
64    unavailable_order_flow: HashSet<String>,
65    metric_source: Option<(Arc<dyn Datafeed>, String)>,
66    metric_catalog: Option<Arc<Inbox>>,
67    metric_names: Vec<String>,
68    metric_error: Option<FeedError>,
69    source: Option<StudySource>,
70    source_error: Option<StudyError>,
71    full: bool,
72    /// Catalogue entries, one per factory name
73    indicators: Vec<IndicatorInfo>,
74    /// Configuration
75    config: IndicatorDialogConfig,
76}
77
78impl Default for IndicatorDialog {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl IndicatorDialog {
85    /// Restrict studies in the factory's Order flow category to services explicitly declared
86    /// for the resolved instrument. Pass [`Chart::feed_capabilities`](crate::widget::Chart::feed_capabilities)
87    /// and [`Chart::instrument`](crate::widget::Chart::instrument) when they change. Missing
88    /// metadata declares no services. Other categories and saved favorites are unchanged.
89    /// Until the first call every study is offered. Requirements are cached between updates.
90    pub fn set_feed_capabilities(
91        &mut self,
92        capabilities: Option<&FeedCapabilities>,
93        instrument: Option<&Instrument>,
94    ) {
95        if self
96            .feed_capabilities
97            .as_ref()
98            .is_some_and(|(previous, symbol)| {
99                previous.as_ref() == capabilities && symbol.as_ref() == instrument
100            })
101        {
102            return;
103        }
104        self.feed_capabilities = Some((capabilities.cloned(), instrument.cloned()));
105        self.refresh_order_flow_availability();
106    }
107
108    fn refresh_order_flow_availability(&mut self) {
109        self.unavailable_order_flow.clear();
110        let Some((capabilities, instrument)) = &self.feed_capabilities else {
111            return;
112        };
113        for ind in &self.indicators {
114            if ind.category != IndicatorFactory::ORDER_FLOW {
115                continue;
116            }
117            let available = self.factory.create(&ind.id).is_some_and(|study| {
118                study.order_flow_inputs().iter().all(|input| {
119                    input.services().iter().all(|service| {
120                        capabilities.as_ref().zip(instrument.as_ref()).is_some_and(
121                            |(capabilities, instrument)| {
122                                capabilities.declares(*service, instrument)
123                            },
124                        )
125                    })
126                })
127            });
128            if !available {
129                self.unavailable_order_flow.insert(ind.id.clone());
130            }
131        }
132    }
133
134    /// Settings to save in the host's user settings document. The dialog owns no storage.
135    pub fn preferences(&self) -> IndicatorPreferences {
136        IndicatorPreferences {
137            favorites: self.favorites.iter().cloned().collect(),
138        }
139    }
140
141    /// Restore the user's saved browser settings, including unavailable host studies.
142    pub fn set_preferences(&mut self, preferences: IndicatorPreferences) {
143        self.favorites = preferences.favorites.into_iter().collect();
144    }
145
146    /// Create a new indicator dialog with the built-in studies
147    pub fn new() -> Self {
148        let factory = IndicatorFactory::new();
149        Self {
150            is_open: false,
151            search: FilterWidget::new()
152                .placeholder("Search indicators")
153                .icon_size(DESIGN_TOKENS.sizing.icon_md),
154            focus_search: false,
155            focus_first_row: false,
156            active_tab: IndicatorTab::Indicators,
157            sel_category: IndicatorCategory::BuiltIns,
158            favorites: HashSet::new(),
159            indicators: IndicatorInfo::from_factory(&factory),
160            factory,
161            feed_capabilities: None,
162            unavailable_order_flow: HashSet::new(),
163            metric_source: None,
164            metric_catalog: None,
165            metric_names: Vec::new(),
166            metric_error: None,
167            source: None,
168            source_error: None,
169            full: false,
170            config: IndicatorDialogConfig::default(),
171        }
172    }
173
174    /// List and create the studies of `factory`, such as the built-ins and the host's own
175    /// added with [`IndicatorFactory::register`]
176    pub fn with_factory(mut self, factory: IndicatorFactory) -> Self {
177        self.indicators = IndicatorInfo::from_factory(&factory);
178        self.factory = factory;
179        self.refresh_order_flow_availability();
180        self
181    }
182
183    /// List the metrics a feed offers for this instrument alongside the technical studies.
184    /// Replacing the feed or symbol discards pending replies from the previous source.
185    pub fn set_metric_source(&mut self, feed: Option<Arc<dyn Datafeed>>, symbol: &str) {
186        if match (&self.metric_source, &feed) {
187            (Some((previous, key)), Some(feed)) => key == symbol && Arc::ptr_eq(previous, feed),
188            (None, None) => true,
189            _ => false,
190        } {
191            return;
192        }
193        self.metric_source = feed.map(|feed| (feed, symbol.to_owned()));
194        self.metric_catalog = None;
195        self.metric_error = None;
196        for name in self.metric_names.drain(..) {
197            self.factory.unregister(&name);
198        }
199        self.indicators = IndicatorInfo::from_factory(&self.factory);
200        self.refresh_order_flow_availability();
201    }
202
203    fn poll_metrics(&mut self, ctx: &egui::Context) {
204        let Some((feed, symbol)) = &self.metric_source else {
205            return;
206        };
207        if self.metric_catalog.is_none() {
208            let inbox = Arc::new(Inbox::default());
209            let ctx = ctx.clone();
210            inbox.set_waker(move || ctx.request_repaint());
211            feed.metrics(symbol, inbox.reply(0));
212            self.metric_catalog = Some(inbox);
213        }
214        for (_, answer) in self.metric_catalog.as_ref().unwrap().drain().answers {
215            let result = answer
216                .downcast::<Result<Vec<MetricSeries>, FeedError>>()
217                .map(|result| *result)
218                .unwrap_or(Err(FeedError::Unanswered));
219            match result {
220                Ok(metrics) => {
221                    for metric in metrics {
222                        if metric.id.is_empty() {
223                            continue;
224                        }
225                        let key = format!("Metric: {}", metric.id);
226                        if self.factory.has(&key) {
227                            continue;
228                        }
229                        let mut info = IndicatorInfo::new(&key, &metric.name, &metric.unit);
230                        info.category = "Metrics".into();
231                        self.factory.register(key.clone(), move || {
232                            Box::new(MetricStudy::new(metric.clone()))
233                        });
234                        self.metric_names.push(key);
235                        self.indicators.push(info);
236                    }
237                    self.indicators.sort_by_key(|info| info.name.to_lowercase());
238                }
239                Err(FeedError::NotSupported) => {}
240                Err(error) => self.metric_error = Some(error),
241            }
242        }
243    }
244
245    /// The catalogue sorted by display name, including the feed's metrics. Entries hidden by
246    /// source or feed capability filters remain in this list.
247    pub fn indicators(&self) -> &[IndicatorInfo] {
248        &self.indicators
249    }
250
251    /// The factory that creates the listed studies, for a host that adds one by its id
252    /// outside the dialog.
253    pub fn factory(&self) -> &IndicatorFactory {
254        &self.factory
255    }
256
257    /// Set a custom dialog configuration; its `TRANSPARENT` colours follow the theme
258    pub fn with_config(mut self, config: IndicatorDialogConfig) -> Self {
259        self.config = config;
260        self
261    }
262
263    /// Open the dialog on the Indicators tab with the search field focused
264    pub fn open(&mut self) {
265        self.open_tab(IndicatorTab::Indicators);
266    }
267
268    /// Open for `chart`. `source` preselects an input for an Add indicator action: the
269    /// dialog then lists only the studies that accept a source, and each one added carries it,
270    /// through [`ConfiguredStudy`] where needed. A study output `chart` does not have leaves
271    /// the dialog closed.
272    pub fn open_for_chart(
273        &mut self,
274        chart: &crate::widget::Chart,
275        source: Option<StudySource>,
276    ) -> bool {
277        if let Some(StudySource::Study { id, plot }) = source
278            && !chart
279                .indicators()
280                .any(|(study, indicator)| study.0 == id && plot < indicator.line_names().len())
281        {
282            self.close();
283            return false;
284        }
285        self.open();
286        self.source = source;
287        true
288    }
289
290    /// Open the dialog on `tab` with the search field focused. Dedicated pages open
291    /// among the built-ins; an unavailable page opens on the first populated page.
292    pub fn open_tab(&mut self, tab: IndicatorTab) {
293        if tab != IndicatorTab::Indicators {
294            self.sel_category = IndicatorCategory::BuiltIns;
295        }
296        self.source = None;
297        self.source_error = None;
298        self.active_tab = tab;
299        self.is_open = true;
300        self.search.clear();
301        self.focus_search = true;
302    }
303
304    /// Whether the chart takes no more studies. The dialog then says so and adds none; call it
305    /// before each [`show`](Self::show), as the cap can be reached while the dialog is open.
306    pub fn set_full(&mut self, full: bool) {
307        self.full = full;
308    }
309
310    /// Close the dialog
311    pub fn close(&mut self) {
312        self.source = None;
313        self.is_open = false;
314    }
315
316    /// Check if dialog is open
317    pub fn is_open(&self) -> bool {
318        self.is_open
319    }
320
321    /// Sync dialog open/close state from an external flag.
322    pub fn sync_open_state(&mut self, should_be_open: bool) {
323        if should_be_open && !self.is_open {
324            self.open();
325        } else if !should_be_open && self.is_open {
326            self.close();
327        }
328    }
329
330    /// Show the dialog with live theme colors
331    pub fn show(&mut self, ctx: &egui::Context, theme: &Theme) -> IndicatorDialogAction {
332        if !self.is_open {
333            return IndicatorDialogAction::None;
334        }
335
336        self.poll_metrics(ctx);
337        let config = self.config.resolved(theme);
338
339        // Escape clears a search before it closes the dialog.
340        if !self.search.is_empty() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape))
341        {
342            self.search.clear();
343        }
344
345        let mut is_open = true;
346        let action = Modal::new("indicator_dialog", "Indicators")
347            .width(config.width)
348            .height(config.height)
349            .dim_background(false)
350            .show(ctx, &mut is_open, |ui| {
351                self.draw_content(ui, &config, theme)
352            })
353            .map_or(IndicatorDialogAction::None, |shown| shown.inner);
354
355        if !is_open {
356            self.close();
357            return IndicatorDialogAction::Close;
358        }
359        action
360    }
361
362    fn draw_content(
363        &mut self,
364        ui: &mut Ui,
365        config: &IndicatorDialogConfig,
366        theme: &Theme,
367    ) -> IndicatorDialogAction {
368        ui.add_space(theme.spacing.xxl);
369        if let Some(error) = &self.metric_error {
370            ui.label(l10n::format_text(
371                ui.ctx(),
372                "Metrics: {error}",
373                &[("error", format!("{}", error))],
374            ));
375        }
376        if let Some(error) = &self.source_error {
377            ui.colored_label(theme.ui.error, l10n::text(ui.ctx(), error.to_string()));
378        }
379        if self.full {
380            ui.colored_label(
381                theme.ui.error,
382                l10n::text(ui.ctx(), "The chart has reached its study limit"),
383            );
384        }
385
386        let search = ui
387            .scope(|ui| {
388                ui.spacing_mut().interact_size.y = 40.0;
389                ui.style_mut().override_font_id = Some(FontId::proportional(theme.text.heading));
390                self.search.show(ui).response
391            })
392            .inner;
393        if std::mem::take(&mut self.focus_search) {
394            search.request_focus();
395        }
396        self.focus_first_row = search.has_focus() && ui.input(|i| i.key_pressed(Key::ArrowDown));
397        ui.add_space(theme.spacing.lg);
398
399        // Only destinations with studies are offered.
400        let offered: Vec<_> = IndicatorCategory::all()
401            .iter()
402            .copied()
403            .filter(|category| self.offers(*category))
404            .collect();
405        if !offered.contains(&self.sel_category) {
406            self.sel_category = offered
407                .iter()
408                .copied()
409                .find(|category| *category != IndicatorCategory::Favorites)
410                .unwrap_or(IndicatorCategory::Favorites);
411        }
412        let mut action = IndicatorDialogAction::None;
413        if ui.available_width() < 600.0 {
414            egui::ComboBox::from_label(l10n::text(ui.ctx(), "Category"))
415                .selected_text(l10n::text(ui.ctx(), self.sel_category.name()))
416                .show_ui(ui, |ui| {
417                    for &category in &offered {
418                        ui.selectable_value(
419                            &mut self.sel_category,
420                            category,
421                            l10n::text(ui.ctx(), category.name()),
422                        );
423                    }
424                });
425            self.draw_tabs(ui, theme);
426            return self.draw_indicator_list(ui, config, theme);
427        }
428        // The sidebar runs before the list, so the list shows a category clicked this frame.
429        let category = Cell::new(self.sel_category);
430        SidebarLayout::new(SIDEBAR_WIDTH).show(
431            ui,
432            |ui| {
433                use IndicatorCategory as C;
434                for (heading, items) in [
435                    ("PERSONAL", &[C::Favorites, C::MyScripts][..]),
436                    ("BUILT-IN", &[C::BuiltIns]),
437                    ("COMMUNITY", &[C::Community]),
438                ] {
439                    let items: Vec<_> = items.iter().filter(|c| offered.contains(c)).collect();
440                    if items.is_empty() {
441                        continue;
442                    }
443                    ui.add_space(theme.spacing.xxl);
444                    egui::Frame::new()
445                        .inner_margin(egui::Margin {
446                            left: theme.spacing.xl as i8,
447                            ..Default::default()
448                        })
449                        .show(ui, |ui| {
450                            ui.label(
451                                RichText::new(l10n::text(ui.ctx(), heading))
452                                    .size(theme.text.small)
453                                    .color(theme.ui.text_muted),
454                            )
455                        });
456                    ui.add_space(theme.spacing.lg);
457                    for &item in items {
458                        if SidebarTab::new(item.name(), category.get() == item)
459                            .icon(item.icon())
460                            .fill(theme.ui.panel_bg_secondary)
461                            .show(ui)
462                        {
463                            category.set(item);
464                        }
465                    }
466                }
467            },
468            |ui| {
469                self.sel_category = category.get();
470                self.draw_tabs(ui, theme);
471                action = self.draw_indicator_list(ui, config, theme);
472            },
473        );
474        action
475    }
476
477    /// The sidebar category and built-in page that list `ind`, from its factory category.
478    fn destination(ind: &IndicatorInfo) -> (IndicatorCategory, IndicatorTab) {
479        use {IndicatorCategory as C, IndicatorTab as T};
480        match ind.category.as_str() {
481            IndicatorFactory::PERSONAL => (C::MyScripts, T::Indicators),
482            IndicatorFactory::COMMUNITY => (C::Community, T::Indicators),
483            IndicatorFactory::PROFILES => (C::BuiltIns, T::Profiles),
484            IndicatorFactory::PATTERNS => (C::BuiltIns, T::Patterns),
485            IndicatorFactory::ORDER_FLOW => (C::BuiltIns, T::OrderFlow),
486            _ => (C::BuiltIns, T::Indicators),
487        }
488    }
489
490    /// Favourites remain available when empty; other categories need registered studies.
491    fn offers(&self, category: IndicatorCategory) -> bool {
492        category == IndicatorCategory::Favorites
493            || self
494                .indicators
495                .iter()
496                .any(|ind| Self::destination(ind).0 == category && self.accepts(ind))
497    }
498
499    /// The built-in pages that have studies, above the built-in results. A search covers
500    /// every page, so they are hidden while one is typed.
501    fn draw_tabs(&mut self, ui: &mut Ui, theme: &Theme) {
502        let tabs: Vec<_> = IndicatorTab::all()
503            .iter()
504            .copied()
505            .filter(|tab| {
506                self.indicators.iter().any(|ind| {
507                    Self::destination(ind) == (IndicatorCategory::BuiltIns, *tab)
508                        && self.accepts(ind)
509                })
510            })
511            .collect();
512        if !tabs.contains(&self.active_tab) {
513            self.active_tab = tabs.first().copied().unwrap_or(IndicatorTab::Indicators);
514        }
515        if self.sel_category == IndicatorCategory::BuiltIns
516            && tabs.len() > 1
517            && self.search.is_empty()
518        {
519            TabBar::new(&tabs, &mut self.active_tab).pills().show(ui);
520            ui.add_space(theme.spacing.xl);
521        }
522    }
523
524    fn accepts(&self, ind: &IndicatorInfo) -> bool {
525        !self.unavailable_order_flow.contains(&ind.id)
526            && (self.source.is_none()
527                || self
528                    .factory
529                    .create(&ind.id)
530                    .is_some_and(|study| study.source().is_some()))
531    }
532
533    /// Whether `ind` belongs to the active category, and page for the built-ins, and matches
534    /// the search.
535    fn matches(&self, ctx: &egui::Context, ind: &IndicatorInfo) -> bool {
536        if !self.accepts(ind) {
537            return false;
538        }
539        let searched = [&ind.name, &ind.desc, &ind.id, &ind.category]
540            .iter()
541            .any(|text| self.search.matches(text) || self.search.matches(&l10n::text(ctx, text)));
542        if !searched {
543            return false;
544        }
545
546        let (category, tab) = Self::destination(ind);
547        match self.sel_category {
548            IndicatorCategory::Favorites => self.favorites.contains(&ind.id),
549            IndicatorCategory::Premium => ind.is_premium,
550            IndicatorCategory::BuiltIns => {
551                category == IndicatorCategory::BuiltIns
552                    && !ind.is_premium
553                    && (!self.search.is_empty() || tab == self.active_tab)
554            }
555            selected => category == selected,
556        }
557    }
558
559    fn draw_indicator_list(
560        &mut self,
561        ui: &mut Ui,
562        config: &IndicatorDialogConfig,
563        theme: &Theme,
564    ) -> IndicatorDialogAction {
565        let mut filtered: Vec<IndicatorInfo> = self
566            .indicators
567            .iter()
568            .filter(|ind| self.matches(ui.ctx(), ind))
569            .cloned()
570            .collect();
571        filtered.sort_by_key(|indicator| !self.favorites.contains(&indicator.id));
572        if filtered.is_empty() {
573            self.draw_empty_state(ui);
574            return IndicatorDialogAction::None;
575        }
576
577        // The column header stays in place above the scrolled rows.
578        let (_, header) = ui.allocate_space(Vec2::new(
579            ui.available_width(),
580            DESIGN_TOKENS.sizing.button_sm,
581        ));
582        let label = ui.painter().layout_no_wrap(
583            l10n::text(ui.ctx(), "NAME"),
584            FontId::proportional(theme.text.small),
585            theme.ui.text_muted,
586        );
587        let label_pos = Pos2::new(
588            header.min.x + DESIGN_TOKENS.sizing.button_md,
589            header.center().y - label.size().y / 2.0,
590        );
591        ui.painter().galley(label_pos, label, theme.ui.text_muted);
592
593        let mut action = IndicatorDialogAction::None;
594        // The scroll bar takes its own column, so it never covers the badges.
595        crate::styles::menu_scroll_bar(ui);
596        egui::ScrollArea::vertical()
597            .auto_shrink([false, false])
598            .show(ui, |ui| {
599                ui.spacing_mut().item_spacing.y = 0.0;
600                // Room above the first row and below the last for the focus ring.
601                let ring = theme.spacing.xs;
602                ui.add_space(ring);
603                for (i, indicator) in filtered.iter().enumerate() {
604                    if let Some(a) = self.draw_indicator_item(ui, config, theme, indicator, i == 0)
605                    {
606                        action = a;
607                    }
608                }
609                ui.add_space(ring);
610            });
611        action
612    }
613
614    /// The empty state for the active search, tab or category.
615    fn draw_empty_state(&self, ui: &mut Ui) {
616        let query = self.search.query();
617        let no_match = l10n::format_text(
618            ui.ctx(),
619            "No indicators match \"{query}\"",
620            &[("query", query)],
621        );
622        let state = match self.sel_category {
623            _ if !query.is_empty() => EmptyState::new(&no_match)
624                .description("Check the spelling or try another category."),
625            IndicatorCategory::Favorites => EmptyState::new("No favorites yet")
626                .icon(&icons::STAR_EMPTY)
627                .description("Star an indicator to keep it here."),
628            _ => EmptyState::new("Nothing here yet"),
629        };
630        state.fill_height().show(ui);
631    }
632
633    fn draw_indicator_item(
634        &mut self,
635        ui: &mut Ui,
636        config: &IndicatorDialogConfig,
637        theme: &Theme,
638        indicator: &IndicatorInfo,
639        is_first: bool,
640    ) -> Option<IndicatorDialogAction> {
641        let desired_size = Vec2::new(
642            ui.available_width(),
643            32.0_f32.max(theme.text.title + 2.0 * theme.spacing.sm),
644        );
645        let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
646
647        let is_favorite = self.favorites.contains(&indicator.id);
648        let translated_label = l10n::text(ui.ctx(), &indicator.name);
649        response.widget_info(|| {
650            WidgetInfo::labeled(Role::ListBoxOption, response.enabled(), &translated_label)
651        });
652        if is_first && self.focus_first_row {
653            response.request_focus();
654        }
655        if response.gained_focus() {
656            response.scroll_to_me(None);
657        }
658
659        if response.hovered() {
660            ui.painter()
661                .rect_filled(rect, theme.radius.md, config.hover_color);
662        }
663        let star_res = Self::draw_favorite_star(
664            ui,
665            config,
666            rect,
667            is_favorite,
668            response.hovered() || response.has_focus(),
669        );
670        let text_right = Self::draw_item_badges(ui, theme, rect, indicator, config.muted_color);
671        Self::draw_item_text(ui, rect, text_right, indicator, config.text_color);
672        draw_focus_ring(ui, &response, theme.radius.md);
673
674        self.handle_item_clicks(indicator, is_favorite, &response, &star_res)
675    }
676
677    /// The favourite toggle at the left of a row.
678    fn star_rect(ui: &Ui, row: Rect) -> Rect {
679        Rect::from_min_size(
680            Pos2::new(row.min.x + ui.active_theme().spacing.sm, row.min.y),
681            Vec2::new(DESIGN_TOKENS.sizing.button_sm, row.height()),
682        )
683    }
684
685    /// The star shows on a favourite and on the row under the pointer or with the focus; the
686    /// toggle stays clickable and focusable while hidden.
687    fn draw_favorite_star(
688        ui: &mut Ui,
689        config: &IndicatorDialogConfig,
690        rect: Rect,
691        is_favorite: bool,
692        row_active: bool,
693    ) -> Response {
694        let star_rect = Self::star_rect(ui, rect);
695        let star_res = ui.allocate_rect(star_rect, Sense::click());
696        let tooltip = if is_favorite {
697            "Remove from favorites"
698        } else {
699            "Add to favorites"
700        };
701        let translated_label = l10n::text(ui.ctx(), tooltip);
702        star_res.widget_info(|| {
703            WidgetInfo::selected(
704                Role::Button,
705                star_res.enabled(),
706                is_favorite,
707                &translated_label,
708            )
709        });
710
711        let (icon, tint) = if is_favorite {
712            (&icons::STAR_FILLED, config.favorite_color)
713        } else if star_res.hovered() {
714            (&icons::STAR_EMPTY, ui.visuals().text_color())
715        } else {
716            (&icons::STAR_EMPTY, ui.visuals().weak_text_color())
717        };
718        if is_favorite || row_active || star_res.hovered() || star_res.has_focus() {
719            let size = Vec2::splat(DESIGN_TOKENS.sizing.icon_sm);
720            icon.as_image_tinted(size, tint)
721                .paint_at(ui, Rect::from_center_size(star_rect.center(), size));
722        }
723        draw_focus_ring(ui, &star_res, 0.0);
724        star_res.on_hover_text(l10n::text(ui.ctx(), tooltip))
725    }
726
727    /// The name centred on the row and cut off before `right`.
728    fn draw_item_text(ui: &Ui, rect: Rect, right: f32, indicator: &IndicatorInfo, text: Color32) {
729        let x = rect.min.x + DESIGN_TOKENS.sizing.button_md;
730        let mut job = LayoutJob::simple_singleline(
731            l10n::text(ui.ctx(), indicator.name.clone()),
732            FontId::proportional(ui.active_theme().text.title),
733            text,
734        );
735        job.wrap = TextWrapping::truncate_at_width(right - x);
736        let name = ui.painter().layout_job(job);
737        let top = rect.center().y - name.size().y / 2.0;
738        ui.painter().galley(Pos2::new(x, top), name, text);
739    }
740
741    /// Outlined badges, right-aligned inside the row padding. Returns where the row text must end.
742    fn draw_item_badges(
743        ui: &Ui,
744        theme: &Theme,
745        rect: Rect,
746        indicator: &IndicatorInfo,
747        color: Color32,
748    ) -> f32 {
749        let painter = ui.painter();
750        let pad = Vec2::new(theme.spacing.sm, theme.spacing.xs);
751        let mut right = rect.right() - theme.spacing.lg;
752        for (shown, label) in [
753            (indicator.is_premium, "PREMIUM"),
754            (indicator.is_overlay, "OVERLAY"),
755        ] {
756            if !shown {
757                continue;
758            }
759            let galley = painter.layout_no_wrap(
760                l10n::text(ui.ctx(), label),
761                FontId::proportional(theme.text.small),
762                color,
763            );
764            let badge = Align2::RIGHT_CENTER.align_size_within_rect(
765                galley.size() + 2.0 * pad,
766                Rect::from_x_y_ranges(rect.left()..=right, rect.y_range()),
767            );
768            painter.rect_stroke(
769                badge,
770                theme.radius.sm,
771                Stroke::new(DESIGN_TOKENS.stroke.hairline, theme.ui.border),
772                StrokeKind::Inside,
773            );
774            painter.galley(badge.min + pad, galley, color);
775            right = badge.left() - theme.spacing.sm;
776        }
777        right
778    }
779
780    fn handle_item_clicks(
781        &mut self,
782        indicator: &IndicatorInfo,
783        is_favorite: bool,
784        response: &Response,
785        star_res: &Response,
786    ) -> Option<IndicatorDialogAction> {
787        if star_res.clicked() {
788            if is_favorite {
789                self.favorites.remove(&indicator.id);
790            } else {
791                self.favorites.insert(indicator.id.clone());
792            }
793            return Some(IndicatorDialogAction::ToggleFavorite(indicator.id.clone()));
794        }
795
796        let on_star = star_res
797            .rect
798            .contains(response.hover_pos().unwrap_or_default());
799        // A click, or Enter or Space on the focused row, adds the study.
800        if response.clicked() && !on_star {
801            return self.add(&indicator.id);
802        }
803        None
804    }
805
806    /// The study registered as `id` with its default inputs and the requested source. A
807    /// source the study rejects is reported in the dialog and adds nothing.
808    fn add(&mut self, id: &str) -> Option<IndicatorDialogAction> {
809        self.source_error = None;
810        if self.full {
811            return None;
812        }
813        let mut study = self.factory.create(id)?;
814        if let Some(source) = self.source {
815            if !study
816                .inputs()
817                .iter()
818                .any(|input| input.id == "source" && matches!(input.kind, InputKind::Source))
819            {
820                study = Box::new(ConfiguredStudy::new(study));
821            }
822            if let Err(error) = study.set_input("source", InputValue::Source(source)) {
823                self.source_error = Some(error);
824                return None;
825            }
826        }
827        Some(IndicatorDialogAction::AddIndicator(study))
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use crate::scales::StudyFormat;
835
836    #[test]
837    fn metric_catalog_ignores_old_symbols_and_creates_host_studies() {
838        use crate::data::{HistoryPage, HistoryRequest, Reply};
839        use std::sync::Mutex;
840        #[derive(Default)]
841        struct Feed(Mutex<Vec<Reply<Vec<MetricSeries>>>>);
842        impl Datafeed for Feed {
843            fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
844                reply.send(Err(FeedError::NotSupported));
845            }
846            fn metrics(&self, _: &str, reply: Reply<Vec<MetricSeries>>) {
847                self.0.lock().unwrap().push(reply);
848            }
849        }
850        let feed = Arc::new(Feed::default());
851        let ctx = egui::Context::default();
852        let mut dialog = IndicatorDialog::new();
853        dialog.set_metric_source(Some(feed.clone()), "OLD");
854        dialog.poll_metrics(&ctx);
855        dialog.set_metric_source(Some(feed.clone()), "CURRENT");
856        dialog.poll_metrics(&ctx);
857        let mut replies = feed.0.lock().unwrap();
858        for (reply, name) in replies.drain(..).zip(["Stale metric", "Funding rate"]) {
859            reply.send(Ok(vec![MetricSeries {
860                id: "funding".into(),
861                name: name.into(),
862                unit: "%".into(),
863                format: StudyFormat::Percent,
864            }]));
865        }
866        dialog.poll_metrics(&ctx);
867        assert!(
868            dialog
869                .indicators
870                .iter()
871                .any(|info| info.name == "Funding rate")
872        );
873        assert!(
874            !dialog
875                .indicators
876                .iter()
877                .any(|info| info.name == "Stale metric")
878        );
879        let Some(IndicatorDialogAction::AddIndicator(study)) = dialog.add("Metric: funding") else {
880            panic!("the metric is added");
881        };
882        assert_eq!(study.name(), "Funding rate");
883        assert!(!study.is_overlay());
884        assert_eq!(study.value_format(), StudyFormat::Percent);
885        assert_eq!(study.data_requests()[0].metric.as_deref(), Some("funding"));
886        use egui_kittest::{Harness, kittest::Queryable};
887        for theme in [Theme::light(), Theme::dark()] {
888            for width in [640.0, 800.0, 1440.0] {
889                dialog.open();
890                *dialog.search.query_mut() = "Funding rate".into();
891                let style = theme.clone();
892                let mut added = false;
893                let mut harness = Harness::builder()
894                    .with_size(Vec2::new(width, 640.0))
895                    .with_context_setup(move |ctx| {
896                        egui_extras::install_image_loaders(ctx);
897                        crate::theme::apply_to_egui(ctx, &style);
898                    })
899                    .build_ui(|ui| {
900                        if let IndicatorDialogAction::AddIndicator(study) =
901                            dialog.show(ui.ctx(), &theme)
902                        {
903                            assert_eq!(study.name(), "Funding rate");
904                            added = true;
905                        }
906                    });
907                harness.run();
908                let row = harness.get_by_role_and_label(Role::ListBoxOption, "Funding rate");
909                assert!(
910                    harness.ctx.content_rect().contains_rect(row.rect()),
911                    "width {width}"
912                );
913                row.click();
914                harness.run();
915                drop(harness);
916                assert!(added, "width {width}");
917            }
918        }
919        dialog.set_metric_source(None, "CURRENT");
920        assert!(dialog.add("Metric: funding").is_none());
921        assert!(dialog.add("SMA").is_some());
922    }
923
924    #[test]
925    fn source_chooser_lists_favorites_first_and_adds_with_the_requested_source() {
926        use egui_kittest::{Harness, kittest::Queryable};
927        let mut chart = crate::widget::ChartBuilder::new().build();
928        let parent = chart.add_indicator(Box::new(SMA::new(3)));
929        let source = StudySource::Study {
930            id: parent.0,
931            plot: 0,
932        };
933        for theme in [Theme::light(), Theme::dark()] {
934            for width in [320.0, 640.0] {
935                let mut dialog = IndicatorDialog::new();
936                let name = dialog
937                    .indicators
938                    .iter()
939                    .find(|info| info.id == "SMA")
940                    .unwrap()
941                    .name
942                    .clone();
943                dialog.favorites.insert("SMA".into());
944                assert!(dialog.open_for_chart(&chart, Some(source)));
945                let mut added = None;
946                let style = theme.clone();
947                let mut harness = Harness::builder()
948                    .with_size(Vec2::new(width, 640.0))
949                    .with_context_setup(move |ctx| {
950                        egui_extras::install_image_loaders(ctx);
951                        crate::theme::apply_to_egui(ctx, &style);
952                    })
953                    .build_ui(|ui| {
954                        if let IndicatorDialogAction::AddIndicator(study) =
955                            dialog.show(ui.ctx(), &theme)
956                        {
957                            added = study.source();
958                        }
959                    });
960                harness.run();
961                let row = harness.get_by_role_and_label(Role::ListBoxOption, &name);
962                assert!(
963                    harness
964                        .query_all_by_role(Role::ListBoxOption)
965                        .all(|other| other.rect().top() >= row.rect().top())
966                );
967                row.click();
968                harness.run();
969                drop(harness);
970                assert_eq!(added, Some(source), "width {width}");
971                assert!(dialog.is_open(), "the dialog stays open for the next study");
972                dialog.open();
973                assert!(
974                    dialog.source.is_none(),
975                    "opening normally clears the previous source"
976                );
977            }
978        }
979    }
980
981    /// A chart at its study cap shows the message and adds no study, whichever way a row is chosen.
982    #[test]
983    fn dialog_at_the_study_limit_says_so_and_adds_nothing() {
984        use egui_kittest::{Harness, kittest::Queryable};
985        let theme = Theme::light();
986        let mut dialog = IndicatorDialog::new();
987        dialog.open();
988        dialog.set_full(true);
989        let style = theme.clone();
990        let mut harness = Harness::builder()
991            .with_size(Vec2::new(640.0, 640.0))
992            .with_context_setup(move |ctx| {
993                egui_extras::install_image_loaders(ctx);
994                crate::theme::apply_to_egui(ctx, &style);
995            })
996            .build_ui(|ui| {
997                dialog.show(ui.ctx(), &theme);
998            });
999        harness.run();
1000        harness.get_by_label("The chart has reached its study limit");
1001        drop(harness);
1002        assert!(dialog.add("SMA").is_none());
1003        dialog.set_full(false);
1004        assert!(dialog.add("SMA").is_some());
1005    }
1006
1007    #[test]
1008    fn saved_favorites_restore_the_favorites_list() {
1009        let mut dialog = IndicatorDialog::new();
1010        dialog
1011            .favorites
1012            .extend(["BB".to_owned(), "Host study".to_owned()]);
1013        let saved = serde_json::to_string(&dialog.preferences()).unwrap();
1014        let mut restored = IndicatorDialog::new();
1015        restored.set_preferences(serde_json::from_str(&saved).unwrap());
1016        restored.sel_category = IndicatorCategory::Favorites;
1017        let ctx = egui::Context::default();
1018        let listed: Vec<_> = restored
1019            .indicators
1020            .iter()
1021            .filter(|i| restored.matches(&ctx, i))
1022            .collect();
1023        assert_eq!(listed.len(), 1);
1024        assert_eq!(listed[0].id, "BB");
1025        assert!(restored.favorites.contains("Host study"));
1026        assert_eq!(
1027            saved,
1028            serde_json::to_string(&restored.preferences()).unwrap()
1029        );
1030        for category in [IndicatorCategory::BuiltIns, IndicatorCategory::Favorites] {
1031            restored.sel_category = category;
1032            let output = ctx.run_ui(Default::default(), |ui| {
1033                restored.draw_indicator_list(ui, &restored.config.clone(), &Theme::dark());
1034            });
1035            let first = output.shapes.iter().find_map(|shape| match &shape.shape {
1036                egui::Shape::Text(text)
1037                    if restored
1038                        .indicators
1039                        .iter()
1040                        .any(|info| info.name == text.galley.job.text) =>
1041                {
1042                    Some(text.galley.job.text.clone())
1043                }
1044                _ => None,
1045            });
1046            output.drop_without_applying_deltas();
1047            let favorite = restored
1048                .indicators
1049                .iter()
1050                .find(|info| info.id == "BB")
1051                .unwrap();
1052            assert_eq!(
1053                first.as_deref(),
1054                Some(favorite.name.as_str()),
1055                "{category:?}"
1056            );
1057        }
1058    }
1059
1060    #[test]
1061    fn added_study_keeps_the_selected_source_and_reopening_clears_it() {
1062        use crate::model::PriceSource;
1063        let mut chart = crate::widget::ChartBuilder::new().build();
1064        let id = chart.add_indicator(Box::new(SMA::new(3)));
1065        let mut dialog = IndicatorDialog::new();
1066        for source in [
1067            StudySource::Study { id: id.0, plot: 0 },
1068            StudySource::Price(PriceSource::Open),
1069        ] {
1070            assert!(dialog.open_for_chart(&chart, Some(source)));
1071            let Some(IndicatorDialogAction::AddIndicator(study)) = dialog.add("SMA") else {
1072                panic!("a source-capable study is added");
1073            };
1074            assert_eq!(study.source(), Some(source));
1075        }
1076        assert!(dialog.open_for_chart(&chart, Some(StudySource::Study { id: id.0, plot: 0 })));
1077        assert!(
1078            dialog.add("ATR").is_none(),
1079            "OHLC studies cannot silently ignore the source"
1080        );
1081        dialog.open();
1082        assert!(matches!(
1083            dialog.add("ATR"),
1084            Some(IndicatorDialogAction::AddIndicator(_))
1085        ));
1086        assert!(!dialog.open_for_chart(&chart, Some(StudySource::Study { id: id.0, plot: 99 })));
1087        assert!(!dialog.is_open());
1088
1089        let mut factory = IndicatorFactory::new();
1090        factory.register("Host source", || {
1091            let mut study = ConfiguredStudy::new(Box::new(SMA::new(3)));
1092            study
1093                .set_input(
1094                    "calculation_timeframe",
1095                    InputValue::Timeframe(Some(crate::model::Timeframe::Hour1)),
1096                )
1097                .unwrap();
1098            Box::new(study)
1099        });
1100        let mut dialog = IndicatorDialog::new().with_factory(factory);
1101        assert!(dialog.open_for_chart(&chart, Some(StudySource::Study { id: id.0, plot: 0 })));
1102        assert!(
1103            dialog.add("Host source").is_none(),
1104            "the host's source validation must be retained"
1105        );
1106        assert!(
1107            matches!(dialog.source_error, Some(StudyError::InvalidInput { ref id, .. }) if id == "source")
1108        );
1109        assert!(dialog.is_open());
1110        let message = dialog.source_error.as_ref().unwrap().to_string();
1111        for theme in [Theme::light(), Theme::dark()] {
1112            for width in [320.0, 640.0] {
1113                use egui_kittest::{Harness, kittest::Queryable};
1114                let style = theme.clone();
1115                let mut harness = Harness::builder()
1116                    .with_size(Vec2::new(width, 640.0))
1117                    .with_context_setup(move |ctx| {
1118                        egui_extras::install_image_loaders(ctx);
1119                        crate::theme::apply_to_egui(ctx, &style);
1120                    })
1121                    .build_ui(|ui| {
1122                        dialog.show(ui.ctx(), &theme);
1123                    });
1124                harness.run();
1125                let error = harness.get_by_label(&message);
1126                assert!(
1127                    harness.ctx.content_rect().contains_rect(error.rect()),
1128                    "source error at width {width}"
1129                );
1130            }
1131        }
1132        assert!(dialog.open_for_chart(&chart, Some(StudySource::Price(PriceSource::Open))));
1133        let Some(IndicatorDialogAction::AddIndicator(study)) = dialog.add("Host source") else {
1134            panic!("a compatible price source is accepted");
1135        };
1136        assert!(dialog.source_error.is_none());
1137        assert_eq!(
1138            study
1139                .inputs()
1140                .iter()
1141                .find(|input| input.id == "calculation_timeframe")
1142                .unwrap()
1143                .value,
1144            InputValue::Timeframe(Some(crate::model::Timeframe::Hour1))
1145        );
1146    }
1147
1148    #[test]
1149    fn each_tab_and_category_lists_only_its_own_indicators() {
1150        use IndicatorCategory::*;
1151        use IndicatorTab::*;
1152        // (tab, category, search, studies listed, studies not listed). A search covers every
1153        // built-in page.
1154        let cases: [(_, _, _, &[&str], &[&str]); 9] = [
1155            (
1156                Indicators,
1157                BuiltIns,
1158                "",
1159                &["RSI", "Host SMA"],
1160                &[
1161                    "Vol Profile VR",
1162                    "All Candlestick Patterns",
1163                    "Execution Delta",
1164                    "Cumulative Execution Delta",
1165                    "Host flow",
1166                    "My SMA",
1167                    "Shared SMA",
1168                ],
1169            ),
1170            (
1171                Profiles,
1172                BuiltIns,
1173                "",
1174                &["Vol Profile VR"],
1175                &["RSI", "All Candlestick Patterns"],
1176            ),
1177            (
1178                Patterns,
1179                BuiltIns,
1180                "",
1181                &["All Candlestick Patterns"],
1182                &["RSI", "Vol Profile VR"],
1183            ),
1184            (
1185                OrderFlow,
1186                BuiltIns,
1187                "",
1188                &["Execution Delta", "Cumulative Execution Delta", "Host flow"],
1189                &["RSI", "Vol Profile VR", "All Candlestick Patterns"],
1190            ),
1191            (
1192                Indicators,
1193                BuiltIns,
1194                "Vol Profile VR",
1195                &["Vol Profile VR"],
1196                &["RSI"],
1197            ),
1198            (
1199                OrderFlow,
1200                BuiltIns,
1201                "delta",
1202                &[
1203                    "Execution Delta",
1204                    "Cumulative Execution Delta",
1205                    "Multi-Range Delta",
1206                ],
1207                &["RSI", "Quoted Spread"],
1208            ),
1209            (
1210                OrderFlow,
1211                Favorites,
1212                "",
1213                &["Execution Delta", "Host flow"],
1214                &["Cumulative Execution Delta", "RSI"],
1215            ),
1216            (
1217                Indicators,
1218                MyScripts,
1219                "",
1220                &["My SMA"],
1221                &["Host SMA", "Shared SMA", "RSI"],
1222            ),
1223            (
1224                Indicators,
1225                Community,
1226                "",
1227                &["Shared SMA"],
1228                &["My SMA", "RSI"],
1229            ),
1230        ];
1231        let factory = || {
1232            let mut factory = IndicatorFactory::new();
1233            factory.register("Host SMA", || Box::new(SMA::new(42)));
1234            factory.register_in(IndicatorFactory::ORDER_FLOW, "Host flow", || {
1235                Box::new(SMA::new(42))
1236            });
1237            factory.register_in(IndicatorFactory::PERSONAL, "My SMA", || {
1238                Box::new(SMA::new(42))
1239            });
1240            factory.register_in(IndicatorFactory::COMMUNITY, "Shared SMA", || {
1241                Box::new(SMA::new(42))
1242            });
1243            factory
1244        };
1245        // The user's scripts and community studies are offered only while a host supplies some.
1246        let mut builtin = IndicatorDialog::new();
1247        let hosted = IndicatorDialog::new().with_factory(factory());
1248        for category in [MyScripts, Community] {
1249            assert!(
1250                !builtin.offers(category) && hosted.offers(category),
1251                "{category:?}"
1252            );
1253        }
1254        let mut searched = IndicatorDialog::new().with_factory(factory());
1255        let study = searched
1256            .indicators
1257            .iter()
1258            .find(|ind| ind.id == "Host SMA")
1259            .unwrap()
1260            .clone();
1261        let name = study.name.clone();
1262        let ctx = egui::Context::default();
1263        for tab in [Indicators, OrderFlow] {
1264            builtin.active_tab = tab;
1265            for ind in &builtin.indicators {
1266                if builtin.factory.category(&ind.id) == Some(IndicatorFactory::ORDER_FLOW) {
1267                    assert_eq!(builtin.matches(&ctx, ind), tab == OrderFlow, "{}", ind.id);
1268                }
1269            }
1270        }
1271        l10n::set_translator(&ctx, move |english| {
1272            (english == name).then(|| "Moyenne locale".into())
1273        });
1274        for query in ["Moyenne", "Host SMA"] {
1275            *searched.search.query_mut() = query.into();
1276            assert!(searched.matches(&ctx, &study), "{query}");
1277        }
1278        for (tab, category, query, listed, unlisted) in cases {
1279            let mut dialog = IndicatorDialog::new().with_factory(factory());
1280            dialog.active_tab = tab;
1281            dialog.sel_category = category;
1282            dialog
1283                .favorites
1284                .extend(["Execution Delta".into(), "Host flow".into()]);
1285            *dialog.search.query_mut() = query.into();
1286            let lists = |id: &str| {
1287                let ind = dialog.indicators.iter().find(|i| i.id == id).unwrap();
1288                dialog.matches(&ctx, ind)
1289            };
1290            for id in listed {
1291                assert_eq!(
1292                    dialog
1293                        .indicators
1294                        .iter()
1295                        .filter(|ind| ind.id == *id && dialog.matches(&ctx, ind))
1296                        .count(),
1297                    1,
1298                    "{tab:?} / {category:?} / {query:?} lists {id} once"
1299                );
1300            }
1301            for id in unlisted {
1302                assert!(!lists(id), "{tab:?} / {category:?} / {query:?} omits {id}");
1303            }
1304        }
1305
1306        // A page needs a registered study the dialog accepts; search still spans all pages.
1307        for has_flow in [false, true] {
1308            for source in [
1309                None,
1310                Some(StudySource::Price(crate::model::PriceSource::Open)),
1311            ] {
1312                let mut factory = IndicatorFactory::new();
1313                if !has_flow {
1314                    for id in factory.list() {
1315                        if factory.category(&id) == Some(IndicatorFactory::ORDER_FLOW) {
1316                            factory.unregister(&id);
1317                        }
1318                    }
1319                }
1320                let mut dialog = IndicatorDialog::new().with_factory(factory);
1321                dialog.open_tab(OrderFlow);
1322                dialog.source = source;
1323                let output = ctx.run_ui(Default::default(), |ui| {
1324                    dialog.draw_tabs(ui, &Theme::dark());
1325                });
1326                let offered = output.shapes.iter().any(|shape| {
1327                    matches!(&shape.shape, egui::Shape::Text(text) if text.galley.job.text == "Order flow")
1328                });
1329                let accepted = has_flow && source.is_none();
1330                assert_eq!(offered, accepted, "{has_flow} / {source:?}");
1331                assert_eq!(
1332                    dialog.active_tab,
1333                    if accepted { OrderFlow } else { Indicators }
1334                );
1335                output.drop_without_applying_deltas();
1336                dialog.source = None;
1337                *dialog.search.query_mut() = "delta".into();
1338                for ind in dialog
1339                    .indicators
1340                    .iter()
1341                    .filter(|ind| ind.name.to_lowercase().contains("delta"))
1342                {
1343                    assert!(dialog.matches(&ctx, ind), "{} / {has_flow}", ind.id);
1344                }
1345                let ids: Vec<_> = dialog
1346                    .indicators
1347                    .iter()
1348                    .filter(|ind| dialog.matches(&ctx, ind))
1349                    .map(|ind| &ind.id)
1350                    .collect();
1351                assert_eq!(ids.len(), ids.iter().collect::<HashSet<_>>().len());
1352            }
1353        }
1354
1355        // A host may replace the catalogue with just one destination.
1356        for (category, selected, tab) in [
1357            (IndicatorFactory::PROFILES, BuiltIns, Profiles),
1358            (IndicatorFactory::PATTERNS, BuiltIns, Patterns),
1359            (IndicatorFactory::ORDER_FLOW, BuiltIns, OrderFlow),
1360            (IndicatorFactory::PERSONAL, MyScripts, Indicators),
1361            (IndicatorFactory::COMMUNITY, Community, Indicators),
1362        ] {
1363            for source in [
1364                None,
1365                Some(StudySource::Price(crate::model::PriceSource::Open)),
1366            ] {
1367                let mut factory = IndicatorFactory::new();
1368                for name in factory.list() {
1369                    factory.unregister(&name);
1370                }
1371                factory.register_in(category, "Host study", || Box::new(ATR::new(14)));
1372                let mut dialog = IndicatorDialog::new().with_factory(factory);
1373                assert!(dialog.open_for_chart(&crate::widget::ChartBuilder::new().build(), source));
1374                ctx.run_ui(Default::default(), |ui| {
1375                    dialog.draw_content(ui, &dialog.config.clone(), &Theme::dark());
1376                })
1377                .drop_without_applying_deltas();
1378                let compatible = source.is_none();
1379                assert_eq!(
1380                    dialog.sel_category,
1381                    if compatible { selected } else { Favorites },
1382                    "{category} / {source:?}"
1383                );
1384                assert_eq!(
1385                    dialog.active_tab,
1386                    if compatible { tab } else { Indicators },
1387                    "{category} / {source:?}"
1388                );
1389                assert_eq!(
1390                    dialog.offers(BuiltIns),
1391                    compatible && selected == BuiltIns,
1392                    "{category} / {source:?}"
1393                );
1394                assert_eq!(
1395                    dialog.matches(&ctx, &dialog.indicators[0]),
1396                    compatible,
1397                    "{category} / {source:?}"
1398                );
1399            }
1400        }
1401
1402        use crate::data::{FeedCapabilities, OrderFlowKind};
1403        use crate::model::{FeedService::*, Instrument};
1404        let instrument = Instrument::new("A");
1405        let none = FeedCapabilities::new().with_services([]);
1406        let live = FeedCapabilities::new().with_services([LiveTrades]);
1407        let sided = FeedCapabilities::new().with_services([LiveTrades, TradeSides]);
1408        let all =
1409            FeedCapabilities::new().with_services([LiveTrades, TradeSides, BestBidAsk, Depth]);
1410        let unknown = FeedCapabilities::new();
1411        let narrowed = instrument.clone().with_services([]);
1412        let declared = instrument.clone().with_services([LiveTrades, TradeSides]);
1413        for (updated, capabilities, symbol, expected) in [
1414            (false, None, None, [true; 4]),
1415            (true, None, None, [false; 4]),
1416            (true, Some(&sided), None, [false; 4]),
1417            (true, None, Some(&declared), [false; 4]),
1418            (true, Some(&unknown), Some(&instrument), [false; 4]),
1419            (true, Some(&none), Some(&instrument), [false; 4]),
1420            (true, Some(&all), Some(&narrowed), [false; 4]),
1421            (true, Some(&none), Some(&declared), [false; 4]),
1422            (
1423                true,
1424                Some(&unknown),
1425                Some(&declared),
1426                [true, true, false, false],
1427            ),
1428            (
1429                true,
1430                Some(&live),
1431                Some(&instrument),
1432                [true, false, false, false],
1433            ),
1434            (
1435                true,
1436                Some(&sided),
1437                Some(&instrument),
1438                [true, true, false, false],
1439            ),
1440            (true, Some(&all), Some(&instrument), [true; 4]),
1441        ] {
1442            let mut dialog = IndicatorDialog::new();
1443            if updated {
1444                dialog.set_feed_capabilities(capabilities, symbol);
1445            }
1446            dialog.favorites.insert("Quoted Spread".into());
1447            dialog.open_tab(OrderFlow);
1448            for query in ["", "Order flow"] {
1449                *dialog.search.query_mut() = query.into();
1450                for (id, accepted) in [
1451                    "Trade Rate",
1452                    "Rolling Volume and Delta",
1453                    "Quoted Spread",
1454                    "Book Size",
1455                ]
1456                .into_iter()
1457                .zip(expected)
1458                {
1459                    let ind = dialog.indicators.iter().find(|ind| ind.id == id).unwrap();
1460                    assert_eq!(
1461                        dialog.matches(&ctx, ind),
1462                        accepted,
1463                        "{id} / {query:?} / {capabilities:?}"
1464                    );
1465                }
1466            }
1467            dialog.search.clear();
1468            let output = ctx.run_ui(Default::default(), |ui| {
1469                dialog.draw_tabs(ui, &Theme::dark())
1470            });
1471            assert_eq!(output.shapes.iter().any(|shape| {
1472                matches!(&shape.shape, egui::Shape::Text(text) if text.galley.job.text == "Order flow")
1473            }), expected.iter().any(|accepted| *accepted));
1474            output.drop_without_applying_deltas();
1475            dialog.sel_category = Favorites;
1476            let spread = dialog
1477                .indicators
1478                .iter()
1479                .find(|ind| ind.id == "Quoted Spread")
1480                .unwrap();
1481            assert_eq!(dialog.matches(&ctx, spread), expected[2]);
1482            assert!(dialog.preferences().favorites.contains("Quoted Spread"));
1483            // Profiles keep their bar-based fallback even though they also expose a footprint.
1484            dialog.sel_category = BuiltIns;
1485            dialog.active_tab = Profiles;
1486            let profile = dialog
1487                .indicators
1488                .iter()
1489                .find(|ind| ind.id == "Vol Profile VR")
1490                .unwrap();
1491            assert!(dialog.matches(&ctx, profile));
1492            dialog.set_feed_capabilities(Some(&all), Some(&instrument));
1493            dialog.sel_category = Favorites;
1494            let spread = dialog
1495                .indicators
1496                .iter()
1497                .find(|ind| ind.id == "Quoted Spread")
1498                .unwrap();
1499            assert!(dialog.matches(&ctx, spread));
1500        }
1501
1502        // Repeated capability updates and frames reuse the requirements; replacing the factory
1503        // reevaluates them, including a host study registered in the Order flow category.
1504        use std::sync::atomic::{AtomicUsize, Ordering};
1505        let builds = Arc::new(AtomicUsize::new(0));
1506        let counted = |kind| {
1507            let builds = builds.clone();
1508            let mut factory = IndicatorFactory::new();
1509            for id in factory.list() {
1510                factory.unregister(&id);
1511            }
1512            factory.register_in(IndicatorFactory::ORDER_FLOW, "Host book", move || {
1513                builds.fetch_add(1, Ordering::Relaxed);
1514                Box::new(OrderFlowStudy::new(kind))
1515            });
1516            factory
1517        };
1518        let mut dialog = IndicatorDialog::new().with_factory(counted(OrderFlowKind::BookSize {
1519            levels: 5,
1520            distance: 0.0,
1521        }));
1522        dialog.set_feed_capabilities(Some(&live), Some(&instrument));
1523        assert!(!dialog.offers(BuiltIns));
1524        let built = builds.load(Ordering::Relaxed);
1525        for _ in 0..3 {
1526            dialog.set_feed_capabilities(Some(&live), Some(&instrument));
1527            ctx.run_ui(Default::default(), |ui| {
1528                dialog.draw_content(ui, &dialog.config.clone(), &Theme::dark());
1529            })
1530            .drop_without_applying_deltas();
1531        }
1532        assert_eq!(builds.load(Ordering::Relaxed), built);
1533        dialog = dialog.with_factory(counted(OrderFlowKind::TradeRate {
1534            volume: false,
1535            seconds: 60,
1536            instant: 3,
1537        }));
1538        assert!(dialog.offers(BuiltIns));
1539        dialog.set_feed_capabilities(Some(&none), Some(&instrument));
1540        assert!(!dialog.offers(BuiltIns));
1541        dialog.set_feed_capabilities(Some(&all), Some(&instrument));
1542        assert!(dialog.offers(BuiltIns));
1543
1544        // A study the host registers, under a new name or a built-in's, is added through the
1545        // factory.
1546        for name in ["Host SMA", "RSI"] {
1547            let mut factory = factory();
1548            factory.register(name, || Box::new(SMA::new(42)));
1549            let mut dialog = IndicatorDialog::new().with_factory(factory);
1550            assert!(dialog.indicators.iter().any(|i| i.id == name));
1551            let Some(IndicatorDialogAction::AddIndicator(study)) = dialog.add(name) else {
1552                panic!("the host's {name} is added");
1553            };
1554            assert_eq!(study.name(), "SMA", "{name}");
1555        }
1556    }
1557
1558    /// Each study is added with the parameters the factory creates it with until the user
1559    /// changes them.
1560    #[test]
1561    fn each_study_is_added_with_its_own_defaults() {
1562        use crate::model::Bar;
1563        use chrono::{TimeZone, Utc};
1564        let bars: Vec<Bar> = (0..300)
1565            .map(|i: i64| {
1566                let x = i as f64;
1567                let close = 100.0 + (x * 0.13).sin() * 8.0 + (x * 0.029).cos() * 5.0;
1568                let open = close - (x * 0.7).sin() * 1.5;
1569                Bar {
1570                    time: Utc.timestamp_opt(i * 3_600, 0).unwrap(),
1571                    open,
1572                    high: close.max(open) + 0.5 + (x * 0.3).sin().abs(),
1573                    low: close.min(open) - 0.5 - (x * 0.5).cos().abs(),
1574                    close,
1575                    volume: 1_000.0 + ((i * 37) % 211) as f64 * 9.0,
1576                    colors: None,
1577                    payload: None,
1578                }
1579            })
1580            .collect();
1581        let factory = IndicatorFactory::new();
1582        let mut dialog = IndicatorDialog::new();
1583        let mut differ = Vec::new();
1584        for info in dialog.indicators.clone() {
1585            let Some(IndicatorDialogAction::AddIndicator(mut added)) = dialog.add(&info.id) else {
1586                panic!("{} is added", info.id);
1587            };
1588            let mut default = factory.create(&info.id).unwrap();
1589            added.calculate(&bars);
1590            default.calculate(&bars);
1591            if format!("{:?}", added.values()) != format!("{:?}", default.values()) {
1592                differ.push(info.id);
1593            }
1594        }
1595        assert!(differ.is_empty(), "added with other parameters: {differ:?}");
1596    }
1597
1598    #[test]
1599    fn show_keeps_the_configuration_a_caller_sets() {
1600        let custom = IndicatorDialogConfig {
1601            width: 900.0,
1602            text_color: Color32::RED,
1603            ..IndicatorDialogConfig::default()
1604        };
1605        let hover = custom.hover_color;
1606        let mut dialog = IndicatorDialog::new().with_config(custom);
1607        dialog.open();
1608        egui::Context::default()
1609            .run_ui(egui::RawInput::default(), |ui| {
1610                dialog.show(ui.ctx(), &Theme::light());
1611            })
1612            .drop_without_applying_deltas();
1613        assert_eq!(dialog.config.width, 900.0);
1614        assert_eq!(dialog.config.text_color, Color32::RED);
1615        assert_eq!(dialog.config.hover_color, hover);
1616    }
1617}