Skip to main content

egui_charts/ui/context_menu/
study.rs

1//! Study context menu - opened from a study's legend row or by a right-click on its plot.
2//!
3//! Offers an alert on the study's value, adding studies, the study's draw order, interval
4//! visibility, pane and scale, copying, hiding and removal, the object tree and the settings.
5
6use crate::chart::series::SeriesId;
7use crate::config::KeyboardAction;
8use crate::icons::icons as embedded_icons;
9use crate::localization as l10n;
10use crate::model::{Alert, AlertCondition, AlertSource, Timeframe};
11use crate::studies::StudyScale;
12use crate::ui::Control;
13use crate::ui::context_menu::{MenuEntry, MenuItem, trim_separators};
14use crate::widget::{Chart, PaneId, VisualOrder};
15
16use egui::Context;
17use std::collections::HashSet;
18
19/// Actions from the study context menu. [`ChartMenus`](crate::ui::context_menu::ChartMenus)
20/// applies the draw order, interval, pane, scale, visibility, removal and settings actions to
21/// the chart as one undoable user action before it returns them; the host carries out the
22/// others.
23#[derive(Debug, Clone, PartialEq)]
24#[non_exhaustive]
25pub enum StudyContextMenuAction {
26    /// Save this alert, on the study's value at the menu's bar, through the host's alerts
27    /// service, for example with
28    /// [`AlertsPanel::save`](crate::ui::widget_bar::panels::AlertsPanel::save).
29    AddAlert(Box<Alert>),
30    /// Open the indicators dialog to add a study.
31    AddIndicator,
32    /// Add the study's catalogue name to the indicators dialog's favorites.
33    AddToFavorites(String),
34    /// Change the study's place in the draw order of its pane.
35    VisualOrder(VisualOrder),
36    /// Show the study only on the intervals of this range around the chart's interval.
37    Intervals(IntervalRange),
38    /// Move the study to another pane.
39    MoveTo(PaneTarget),
40    /// Pin the study to a scale.
41    PinTo(StudyScale),
42    /// Copy the study with [`Chart::copy_indicator`] to the clipboard.
43    Copy,
44    /// Hide the study, or show it again.
45    ToggleVisibility,
46    /// Remove the study.
47    Remove,
48    /// Open the object tree panel. The chart has selected the study.
49    ObjectTree,
50    /// Open the study's settings, through [`Chart::take_indicator_settings_request`].
51    Settings,
52}
53
54/// Intervals on which a study is shown, relative to the chart's interval.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum IntervalRange {
57    /// The chart's interval and every longer one.
58    AndAbove,
59    /// The chart's interval and every shorter one.
60    AndBelow,
61    /// The chart's interval alone.
62    Only,
63    /// Every interval.
64    All,
65}
66
67impl IntervalRange {
68    /// The preset intervals and `current` on which the range hides a study, by duration.
69    pub fn hidden_intervals(self, current: Timeframe) -> Vec<Timeframe> {
70        let mut intervals = Timeframe::all();
71        if !intervals.contains(&current) {
72            intervals.push(current);
73        }
74        intervals.retain(|interval| match self {
75            Self::AndAbove => interval.duration_ms() < current.duration_ms(),
76            Self::AndBelow => interval.duration_ms() > current.duration_ms(),
77            Self::Only => *interval != current,
78            Self::All => false,
79        });
80        intervals
81    }
82}
83
84/// Where the study context menu moves a study.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum PaneTarget {
87    /// A new pane directly above the study's pane.
88    NewPaneAbove,
89    /// A new pane directly below the study's pane, or below the main pane.
90    NewPaneBelow,
91    /// An existing pane, addressed by its stable id.
92    Pane(PaneId),
93    /// The main pane.
94    Main,
95}
96
97/// The panes of `chart` other than the price pane and `current`, by their stable ids, with
98/// titles naming their first series or study.
99pub(crate) fn other_panes(
100    ctx: Option<&Context>,
101    chart: &Chart,
102    current: Option<PaneId>,
103) -> Vec<(PaneId, String)> {
104    chart
105        .panes()
106        .into_iter()
107        .filter(|other| *other != PaneId::MAIN && Some(*other) != current)
108        .map(|other| {
109            let (title, builtin) =
110                chart
111                    .pane_series(other)
112                    .first()
113                    .map_or(("empty", true), |member| {
114                        chart.indicators().find(|(id, _)| id == member).map_or_else(
115                            || {
116                                chart
117                                    .series()
118                                    .find(|(id, _)| id == member)
119                                    .map_or(("Series", true), |(_, options)| {
120                                        (options.name.as_str(), false)
121                                    })
122                            },
123                            |(_, study)| (study.name(), false),
124                        )
125                    });
126            let translated = if builtin {
127                ctx.and_then(|ctx| l10n::lookup(ctx, title))
128            } else {
129                None
130            };
131            let title = translated.as_deref().unwrap_or(title);
132            (
133                other,
134                match ctx {
135                    Some(ctx) => l10n::format_text(ctx, "{0} pane", &[("0", title)]),
136                    None => l10n::substitute("{0} pane", &[("0", title)]),
137                },
138            )
139        })
140        .collect()
141}
142
143/// The study context menu of [`ChartMenus`](crate::ui::context_menu::ChartMenus), with the
144/// study's state taken when it opened.
145#[derive(Default)]
146pub(crate) struct StudyContextMenu {
147    is_open: bool,
148    name: String,
149    catalogue_name: String,
150    alert: Option<(Alert, String)>,
151    visible: bool,
152    overlay: bool,
153    volume: bool,
154    scale: Option<StudyScale>,
155    /// The side of the pane's price axis.
156    axis_side: &'static str,
157    /// Other panes by their stable ids, with their titles.
158    panes: Vec<(PaneId, String)>,
159    has_interval: bool,
160    next_scale: u64,
161    shared_scales: Vec<u64>,
162    /// Controls the host hid, set through
163    /// [`ChartMenus::set_control_visible`](crate::ui::context_menu::ChartMenus::set_control_visible).
164    pub(crate) hidden: HashSet<Control>,
165}
166
167impl StudyContextMenu {
168    /// Open the menu on study `id` of `chart` at the rendered bar `bar`.
169    pub(crate) fn open(&mut self, ctx: &Context, chart: &Chart, id: SeriesId, bar: usize) {
170        let Some((_, study)) = chart.indicators().find(|(study, _)| *study == id) else {
171            self.is_open = false;
172            return;
173        };
174        self.is_open = true;
175        self.volume = study.is_volume();
176        self.name = if self.volume { "Vol" } else { study.name() }.to_owned();
177        self.catalogue_name = study.name().to_owned();
178        self.alert = chart
179            .indicator_value_at(id, bar)
180            .filter(|_| !chart.symbol.is_empty())
181            .map(|(output, value)| {
182                let mut alert = Alert::new(chart.symbol.clone(), AlertCondition::Crossing(value));
183                alert.source = if self.volume {
184                    AlertSource::Volume
185                } else {
186                    AlertSource::Study {
187                        id: id.0.to_string(),
188                        output,
189                    }
190                };
191                (alert, study.format_plot_value(output, value))
192            });
193        self.visible = study.is_visible();
194        self.overlay = study.is_overlay();
195        self.scale = Some(study.scale());
196        self.shared_scales = chart
197            .indicators()
198            .filter_map(|(_, study)| match study.scale() {
199                StudyScale::Shared(crate::model::SeriesScale::Separate(key)) => Some(key),
200                _ => None,
201            })
202            .chain(
203                chart
204                    .series()
205                    .filter_map(|(_, options)| match options.scale {
206                        crate::model::SeriesScale::Separate(key) => Some(key),
207                        _ => None,
208                    }),
209            )
210            .collect();
211        self.shared_scales.sort_unstable();
212        self.shared_scales.dedup();
213        self.next_scale = (0..)
214            .find(|key| !self.shared_scales.contains(key))
215            .unwrap_or(0);
216        self.axis_side = if chart.config().show_right_axis || !chart.config().show_left_axis {
217            "right"
218        } else {
219            "left"
220        };
221        self.panes = other_panes(Some(ctx), chart, chart.series_pane(id));
222        self.has_interval = chart.timeframe().is_some();
223    }
224
225    pub(crate) fn close(&mut self) {
226        self.is_open = false;
227    }
228
229    pub(crate) fn is_open(&self) -> bool {
230        self.is_open
231    }
232
233    pub(crate) fn build_menu_items(
234        &self,
235        ctx: &Context,
236        shortcuts: &crate::widget::ShortcutMap,
237    ) -> Vec<MenuEntry<StudyContextMenuAction>> {
238        use StudyContextMenuAction as A;
239        if !self.is_open {
240            return Vec::new();
241        }
242        let name = if self.volume {
243            l10n::text(ctx, &self.name)
244        } else {
245            self.name.clone()
246        };
247        let item = |label: &str, action| MenuEntry::Item(MenuItem::new(label, action));
248        let mut items = Vec::new();
249        if let Some((alert, value)) = &self.alert {
250            items.push(MenuEntry::Item(
251                MenuItem::new(
252                    l10n::format_text(
253                        ctx,
254                        "Add alert on {name} at {price}…",
255                        &[("name", name.as_str()), ("price", value.as_str())],
256                    ),
257                    A::AddAlert(Box::new(alert.clone())),
258                )
259                .with_icon(&embedded_icons::ALERTS)
260                .with_shortcut(shortcuts.shortcut(KeyboardAction::CreateAlert)),
261            ));
262        }
263        items.extend([
264            MenuEntry::Item(
265                MenuItem::new(
266                    l10n::format_text(ctx, "Add indicator on {name}…", &[("name", name.as_str())]),
267                    A::AddIndicator,
268                )
269                .with_icon(&embedded_icons::INDICATORS),
270            ),
271            MenuEntry::Item(
272                MenuItem::new(
273                    "Add this indicator to favorites",
274                    A::AddToFavorites(self.catalogue_name.clone()),
275                )
276                .with_icon(&embedded_icons::STAR_EMPTY),
277            ),
278            MenuEntry::Separator,
279            MenuEntry::Submenu {
280                label: "Visual order".into(),
281                icon: Some(&embedded_icons::WIDGET_BAR_LAYERS),
282                entries: [
283                    ("Bring to front", VisualOrder::BringToFront),
284                    ("Bring forward", VisualOrder::BringForward),
285                    ("Send backward", VisualOrder::SendBackward),
286                    ("Send to back", VisualOrder::SendToBack),
287                ]
288                .into_iter()
289                .map(|(label, order)| item(label, A::VisualOrder(order)))
290                .collect(),
291            },
292        ]);
293        if self.has_interval {
294            items.push(MenuEntry::Submenu {
295                label: "Visibility on intervals".into(),
296                icon: None,
297                entries: [
298                    ("Current interval and above", IntervalRange::AndAbove),
299                    ("Current interval and below", IntervalRange::AndBelow),
300                    ("Current interval only", IntervalRange::Only),
301                    ("All intervals", IntervalRange::All),
302                ]
303                .into_iter()
304                .map(|(label, range)| item(label, A::Intervals(range)))
305                .collect(),
306            });
307        }
308        let mut moves = Vec::new();
309        moves.push(item("New pane above", A::MoveTo(PaneTarget::NewPaneAbove)));
310        moves.push(item("New pane below", A::MoveTo(PaneTarget::NewPaneBelow)));
311        for (pane, title) in &self.panes {
312            moves.push(item(title, A::MoveTo(PaneTarget::Pane(*pane))));
313        }
314        if !self.overlay {
315            moves.push(item("Main pane", A::MoveTo(PaneTarget::Main)));
316        }
317        items.push(MenuEntry::Submenu {
318            label: "Move to".into(),
319            icon: Some(&embedded_icons::MOVE_PANE),
320            entries: moves,
321        });
322        let side = self.axis_side;
323        let now = match self.scale.unwrap_or(StudyScale::None) {
324            StudyScale::Main => l10n::text(ctx, side),
325            StudyScale::Independent if self.overlay && self.volume => l10n::text(ctx, "no scale"),
326            StudyScale::Independent if self.overlay && !self.volume => {
327                l10n::text(ctx, "independent left")
328            }
329            StudyScale::Independent => l10n::text(ctx, "independent"),
330            StudyScale::None => l10n::text(ctx, "no scale"),
331            StudyScale::Shared(crate::model::SeriesScale::Right) => l10n::text(ctx, "right"),
332            StudyScale::Shared(crate::model::SeriesScale::Left) => l10n::text(ctx, "left"),
333            StudyScale::Shared(crate::model::SeriesScale::Separate(key)) => {
334                l10n::format_text(ctx, "scale {key}", &[("key", key.to_string().as_str())])
335            }
336            StudyScale::Shared(crate::model::SeriesScale::Overlay(_)) => {
337                l10n::text(ctx, "no scale")
338            }
339        };
340        let mut entries = vec![
341            item(
342                "Pin to right scale",
343                A::PinTo(StudyScale::Shared(crate::model::SeriesScale::Right)),
344            ),
345            item(
346                "Pin to left scale",
347                A::PinTo(StudyScale::Shared(crate::model::SeriesScale::Left)),
348            ),
349            item(
350                "Pin to new scale",
351                A::PinTo(StudyScale::Shared(crate::model::SeriesScale::Separate(
352                    self.next_scale,
353                ))),
354            ),
355        ];
356        for key in &self.shared_scales {
357            entries.push(item(
358                &l10n::format_text(
359                    ctx,
360                    "Pin to scale {key}",
361                    &[("key", key.to_string().as_str())],
362                ),
363                A::PinTo(StudyScale::Shared(crate::model::SeriesScale::Separate(
364                    *key,
365                ))),
366            ));
367        }
368        entries.push(item("No scale", A::PinTo(StudyScale::None)));
369        items.push(MenuEntry::Submenu {
370            label: l10n::format_text(ctx, "Pin to scale (now {now})", &[("now", now.as_str())]),
371            icon: Some(&embedded_icons::PIN_TO_SCALE),
372            entries,
373        });
374        items.extend([
375            MenuEntry::Separator,
376            MenuEntry::Item(
377                MenuItem::new("Copy", A::Copy)
378                    .with_shortcut(shortcuts.shortcut(KeyboardAction::CopyDrawing)),
379            ),
380            MenuEntry::Item(if self.visible {
381                MenuItem::new("Hide", A::ToggleVisibility).with_icon(&embedded_icons::EYE_HIDE)
382            } else {
383                MenuItem::new("Show", A::ToggleVisibility).with_icon(&embedded_icons::EYE)
384            }),
385            MenuEntry::Item(
386                MenuItem::new("Remove", A::Remove)
387                    .with_icon(&embedded_icons::TRASH)
388                    .with_shortcut(shortcuts.shortcut(KeyboardAction::DeleteSelectedDrawing)),
389            ),
390            MenuEntry::Separator,
391            item("Object tree", A::ObjectTree),
392            MenuEntry::Separator,
393            MenuEntry::Item(
394                MenuItem::new("Settings…", A::Settings).with_icon(&embedded_icons::SETTINGS_GEAR),
395            ),
396        ]);
397        items.retain(|entry| {
398            let control = match entry {
399                MenuEntry::Item(item) => match item.action {
400                    A::AddAlert(_) => Control::Alerts,
401                    A::AddIndicator | A::AddToFavorites(_) => Control::Indicators,
402                    A::ObjectTree => Control::ObjectTree,
403                    A::Settings => Control::Settings,
404                    _ => return true,
405                },
406                MenuEntry::Submenu { .. } | MenuEntry::Separator => return true,
407            };
408            !self.hidden.contains(&control)
409        });
410        trim_separators(&mut items);
411        items
412    }
413}
414
415/// Apply a chart-owned action to study `id`. The caller wraps it in a user action.
416pub(crate) fn apply(chart: &mut Chart, id: SeriesId, action: &StudyContextMenuAction) {
417    use StudyContextMenuAction as A;
418    match action {
419        A::VisualOrder(order) => {
420            chart.reorder_indicator(id, *order);
421        }
422        A::Intervals(range) => {
423            if let Some(current) = chart.timeframe()
424                && let Some(study) = chart.indicator_mut(id)
425            {
426                let mut settings = study.settings();
427                settings.hidden_intervals = range.hidden_intervals(current);
428                settings.interval_range = match range {
429                    IntervalRange::All => crate::studies::StudyIntervalRange::All,
430                    IntervalRange::Only => crate::studies::StudyIntervalRange::Only(current),
431                    IntervalRange::AndAbove => {
432                        crate::studies::StudyIntervalRange::AndAbove(current)
433                    }
434                    IntervalRange::AndBelow => {
435                        crate::studies::StudyIntervalRange::AndBelow(current)
436                    }
437                };
438                // A study without editable presentation keeps showing on every interval.
439                let _ = study.apply_settings(&[], settings);
440            }
441        }
442        A::MoveTo(target) => {
443            match *target {
444                PaneTarget::NewPaneAbove => chart.move_indicator_to_new_pane(id, true),
445                PaneTarget::NewPaneBelow => chart.move_indicator_to_new_pane(id, false),
446                PaneTarget::Pane(pane) => chart.move_series_to_pane(id, pane),
447                PaneTarget::Main => chart.move_series_to_pane(id, PaneId::MAIN),
448            };
449        }
450        A::PinTo(scale) => {
451            chart.set_indicator_scale(id, *scale);
452        }
453        A::ToggleVisibility => {
454            let visible = chart
455                .indicators()
456                .find(|(study, _)| *study == id)
457                .is_some_and(|(_, study)| study.is_visible());
458            chart.set_indicator_visible(id, !visible);
459        }
460        A::Remove => {
461            chart.remove_series(id);
462        }
463        A::Settings => chart.indicator_settings_request = Some(id),
464        A::AddIndicator => {
465            let source =
466                chart
467                    .indicators()
468                    .find(|(study, _)| *study == id)
469                    .and_then(|(_, study)| {
470                        (0..study.line_count())
471                            .find(|line| {
472                                let plot = study.plot_style(*line);
473                                plot.visible
474                                    && !matches!(
475                                        plot.plot_type,
476                                        crate::studies::PlotType::BarColor
477                                            | crate::studies::PlotType::BackgroundColor
478                                    )
479                            })
480                            .map(|plot| crate::studies::StudySource::Study { id: id.0, plot })
481                    });
482            chart.indicator_source_request = source;
483        }
484        A::AddAlert(_) | A::AddToFavorites(_) | A::Copy | A::ObjectTree => {}
485    }
486}