Skip to main content

egui_charts/ui/context_menu/chart_menus/
mod.rs

1use super::{ChartContextMenu, ContextMenuAction, MenuEntry, MenuItem, POINTER_GAP, show_menu};
2use crate::chart::selection::{ChartElementId, SeriesId};
3use crate::icons::Icon;
4use crate::model::Capabilities;
5use crate::theme::ThemeExt;
6use crate::ui::Control;
7use crate::ui::app_state::set_control_visible;
8use crate::ui::context_menu::drawing::DRAWING_MENU_OFFSET;
9use crate::ui::context_menu::study::{self as study_context_menu, StudyContextMenu};
10use crate::ui::context_menu::tpo::TpoMenu;
11use crate::ui::context_menu::trade_profile::TradeProfileMenu;
12use crate::ui::dialogs::{DrawingContextMenu, SeriesContextMenu};
13use crate::widget::{Chart, RightClickTarget};
14use egui::{Context, Id};
15
16/// The original click retained while a host menu action is being chosen.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct HostMenuContext {
19    /// Background, series, study or drawing that was clicked, including its screen position.
20    pub target: RightClickTarget,
21    /// UTC time of the nearest displayed bar or element; `None` outside loaded data.
22    pub time: Option<chrono::DateTime<chrono::Utc>>,
23    /// Value at the click, using the clicked pane's scale for drawings.
24    pub price: f64,
25}
26
27/// A host row appended to the chart, series and drawing menus in registration order.
28///
29/// Use [`Self::when`] to restrict a row to particular click targets. Host ids must be unique
30/// within one [`ChartMenus`]. The host owns any services invoked by the returned action.
31pub struct HostMenuEntry {
32    /// Stable id returned in [`ChartContextAction::Host`].
33    pub id: String,
34    /// Text painted with the built-in menu style.
35    pub label: String,
36    /// Optional icon painted with the built-in tint and size.
37    pub icon: Option<&'static Icon>,
38    condition: Box<dyn Fn(&HostMenuContext) -> bool>,
39}
40
41impl HostMenuEntry {
42    /// Create a row visible for every chart, series and drawing click.
43    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
44        Self {
45            id: id.into(),
46            label: label.into(),
47            icon: None,
48            condition: Box::new(|_| true),
49        }
50    }
51
52    /// Paint an icon beside the label.
53    pub fn with_icon(mut self, icon: &'static Icon) -> Self {
54        self.icon = Some(icon);
55        self
56    }
57
58    /// Omit this row when the predicate is false for the original click.
59    pub fn when(mut self, condition: impl Fn(&HostMenuContext) -> bool + 'static) -> Self {
60        self.condition = Box::new(condition);
61        self
62    }
63}
64
65/// A context-menu action surfaced by the turnkey right-click path, tagged with
66/// the menu it came from.
67///
68/// The chart opens one of three menus depending on what the right-click landed
69/// on (background, a series/indicator, or a drawing); each menu has its own
70/// action enum. [`ChartMenus::show`] returns this unified value so a host can match a single
71/// type.
72#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub enum ChartContextAction {
75    /// A registered host entry, with the original click context.
76    Host {
77        /// The registered entry id.
78        id: String,
79        /// The click that opened the menu, retained even if the pointer or chart moves.
80        context: HostMenuContext,
81    },
82    /// An action from the chart-background menu.
83    Chart(ContextMenuAction),
84    /// An action from the series menu.
85    Series(crate::ui::dialogs::SeriesContextMenuAction),
86    /// An action from the menu of study `id`, opened from its legend row or plot.
87    Study {
88        /// The study the menu acts on.
89        id: SeriesId,
90        /// The chosen action, already applied to the chart when it is the chart's to apply.
91        action: crate::ui::dialogs::StudyContextMenuAction,
92    },
93    /// An action from the drawing menu.
94    Drawing(crate::ui::dialogs::DrawingContextMenuAction),
95}
96
97/// The chart, series and drawing context menus of one [`Chart`].
98///
99/// Keep one next to the chart and call [`show`](Self::show) after each
100/// [`Chart::show`]: a right-click on the chart opens the menu for what it landed on, and the
101/// item the user picks comes back as a [`ChartContextAction`]. The menus follow the theme
102/// applied with [`apply_to_egui`](crate::theme::apply_to_egui).
103///
104/// ```
105/// use egui_charts::ui::context_menu::{ChartContextAction, ChartMenus, HostMenuEntry};
106/// use egui_charts::widget::RightClickTarget;
107/// let mut menus = ChartMenus::default();
108/// menus.host_entries.push(HostMenuEntry::new("ticket", "Send to ticket")
109///     .when(|click| matches!(click.target, RightClickTarget::Background { .. })));
110/// // After chart.show(ui), handle ChartContextAction::Host { id, context } from menus.show.
111/// ```
112#[derive(Default)]
113pub struct ChartMenus {
114    chart: ChartContextMenu,
115    series: SeriesContextMenu,
116    study: StudyContextMenu,
117    drawing: DrawingContextMenu,
118    axes: crate::ui::context_menu::axis::AxisMenus,
119    tpo: Option<TpoMenu>,
120    trade_profile: Option<TradeProfileMenu>,
121    /// Host rows in display order. Empty by default.
122    pub host_entries: Vec<HostMenuEntry>,
123    context: Option<HostMenuContext>,
124    just_opened: bool,
125}
126
127impl ChartMenus {
128    /// The price of the series menu's last right-click, which stays after a pick.
129    pub fn series_click_price(&self) -> f64 {
130        self.series.click_price
131    }
132
133    /// Shows or hides named menu entries: Alerts, Trading, Settings, ObjectTree, Indicators,
134    /// ResetView, CopyPrice, ExportData, TimeZone and Session. `ContextMenus` disables all menus,
135    /// including drawings and axes. Controls belonging only to toolbars are ignored.
136    pub fn set_control_visible(&mut self, control: Control, visible: bool) {
137        set_control_visible(&mut self.chart.hidden, control, visible);
138        set_control_visible(&mut self.series.hidden, control, visible);
139        set_control_visible(&mut self.study.hidden, control, visible);
140        set_control_visible(&mut self.axes.hidden, control, visible);
141    }
142
143    /// Sets the quantity of the chart menu's order entries and the order types the account
144    /// accepts, such as [`Trading::quantity`](crate::ui::trading::Trading::quantity) and
145    /// [`Trading::capabilities`](crate::ui::trading::Trading::capabilities). By default the
146    /// entries are for 1 unit and offer limit and stop orders.
147    pub fn set_order_entries(&mut self, quantity: f64, capabilities: Capabilities) {
148        self.chart.order_quantity = quantity;
149        self.chart.order_types = capabilities;
150    }
151
152    /// Offers adding the chart's symbol to these watchlists, such as the names of
153    /// [`WatchlistPanel::lists`](crate::ui::widget_bar::WatchlistPanel::lists), in a submenu.
154    /// The choice returns as [`ContextMenuAction::AddToWatchlist`] with the list's index. Without
155    /// names the menu has no such entry.
156    pub fn set_watchlists(&mut self, names: impl IntoIterator<Item = impl Into<String>>) {
157        self.chart.watchlists = names.into_iter().map(Into::into).collect();
158    }
159
160    /// Opens the menu for the chart's last right-click, shows the open one and returns the
161    /// chosen action. While a menu is open the chart draws no crosshair or tooltip under it.
162    pub fn show(&mut self, ctx: &Context, chart: &mut Chart) -> Option<ChartContextAction> {
163        if self.chart.hidden.contains(&Control::ContextMenus) {
164            self.chart.close();
165            self.series.close();
166            self.study.close();
167            self.drawing.close();
168            self.axes.close();
169            self.context = None;
170            chart.take_right_click();
171            chart.menu_open = false;
172            return None;
173        }
174        if self.axes.open_from_pointer(ctx, chart) {
175            self.chart.close();
176            self.series.close();
177            self.study.close();
178            self.drawing.close();
179            self.context = None;
180        }
181        if let Some(target) = chart.take_right_click() {
182            self.axes.close();
183            let pos = match target {
184                RightClickTarget::Element { pos, .. }
185                | RightClickTarget::Drawing { pos, .. }
186                | RightClickTarget::Background { pos, .. } => pos,
187            };
188            let mapping = chart.get_chart_mapping();
189            let index = mapping.x_to_idx_f64(pos.x).round();
190            let time = (index >= 0.0)
191                .then(|| chart.state().shown().get(index as usize))
192                .flatten()
193                .map(|bar| bar.time);
194            let price = match target {
195                RightClickTarget::Element { price, .. }
196                | RightClickTarget::Background { price, .. } => price,
197                RightClickTarget::Drawing { .. } => chart
198                    .get_rendered_indicator_panes()
199                    .iter()
200                    .find(|pane| pane.chart_rect.contains(pos))
201                    .map_or_else(
202                        || mapping.y_to_price(pos.y),
203                        |pane| pane.mapping.y_to_price(pos.y),
204                    ),
205            };
206            self.context = Some(HostMenuContext {
207                target,
208                time,
209                price,
210            });
211            self.tpo = match target {
212                RightClickTarget::Background { .. }
213                | RightClickTarget::Element {
214                    id: ChartElementId::Series(SeriesId::MAIN),
215                    ..
216                } => TpoMenu::open(chart, pos),
217                _ => None,
218            };
219            self.trade_profile = match target {
220                RightClickTarget::Background { .. }
221                | RightClickTarget::Element {
222                    id: ChartElementId::Series(SeriesId::MAIN),
223                    ..
224                } => TradeProfileMenu::open(chart, pos, time),
225                _ => None,
226            };
227            self.just_opened = true;
228            // A new right-click supersedes any menu still open.
229            self.chart.close();
230            self.series.close();
231            self.study.close();
232            self.drawing.close();
233            match target {
234                RightClickTarget::Element {
235                    id: ChartElementId::Indicator(id),
236                    bar_idx,
237                    ..
238                } => self.study.open(ctx, chart, id, bar_idx),
239                RightClickTarget::Element {
240                    id: ChartElementId::Series(id),
241                    pos,
242                    price,
243                    ..
244                } => {
245                    self.series.set_context(chart.symbol.clone(), price);
246                    self.series.open(pos, id);
247                }
248                RightClickTarget::Drawing { drawing_id, pos } => {
249                    self.drawing.open_on(ctx, chart, pos, drawing_id);
250                }
251                RightClickTarget::Background { pos, price } => {
252                    self.chart.set_indicator_cnt(chart.indicators().count());
253                    self.chart.set_symbol(chart.symbol.clone());
254                    self.chart.market_price =
255                        crate::model::last_with_value(chart.bars(SeriesId::MAIN)).map(|b| b.close);
256                    self.chart.tick_size = chart
257                        .instrument()
258                        .and_then(|instrument| instrument.tick_size)
259                        .filter(|tick| tick.is_finite() && *tick > 0.0);
260                    let line = chart
261                        .drawings()
262                        .registry
263                        .availability(crate::drawings::DrawingToolType::HorizontalLine);
264                    self.chart.horizontal_line = line.visible && line.enabled;
265                    self.chart.open(pos, price);
266                }
267            }
268        }
269
270        let theme = ctx.active_theme();
271        let mut menu_action = None;
272        if let Some(context) = self.context {
273            let (pos, gap) = match context.target {
274                RightClickTarget::Element { pos, .. }
275                | RightClickTarget::Background { pos, .. } => (pos, POINTER_GAP),
276                RightClickTarget::Drawing { pos, .. } => (pos, DRAWING_MENU_OFFSET),
277            };
278            let entries = self.build_menu_items(ctx, chart.shortcuts(), context);
279            if let Some(picked) = show_menu(
280                ctx,
281                Id::unique("chart_host_menu"),
282                &theme,
283                pos,
284                gap,
285                &entries,
286                std::mem::take(&mut self.just_opened),
287            ) {
288                if let Some(ChartContextAction::Study { id, action }) = &picked {
289                    chart.user_action(|chart| study_context_menu::apply(chart, *id, action));
290                }
291                if let Some(ChartContextAction::Chart(action)) = &picked
292                    && let Some(tpo) = &self.tpo
293                {
294                    chart.user_action(|chart| tpo.apply(chart, action));
295                }
296                if let Some(ChartContextAction::Chart(action)) = &picked
297                    && let Some(profile) = &self.trade_profile
298                {
299                    profile.apply(chart, action);
300                }
301                if let Some(ChartContextAction::Chart(ContextMenuAction::DrawHorizontalLine(price))) =
302                    &picked
303                    && let RightClickTarget::Background { pos, .. } = context.target
304                {
305                    chart.user_action(|chart| chart.draw_horizontal_line_at(pos, *price));
306                }
307                menu_action = picked;
308                self.context = None;
309                self.chart.close();
310                self.series.close();
311                self.study.close();
312                self.drawing.close();
313            }
314        }
315        let axis_action = chart.user_action(|chart| self.axes.show(ctx, chart));
316        chart.menu_open = self.chart.is_open()
317            || self.series.is_open()
318            || self.study.is_open()
319            || self.drawing.is_open()
320            || self.axes.is_open();
321
322        // At most one menu is open at a time, so at most one action resolves.
323        axis_action.map(ChartContextAction::Chart).or(menu_action)
324    }
325
326    fn build_menu_items(
327        &self,
328        ctx: &Context,
329        shortcuts: &crate::widget::ShortcutMap,
330        context: HostMenuContext,
331    ) -> Vec<MenuEntry<ChartContextAction>> {
332        fn tagged<A>(
333            entries: Vec<MenuEntry<A>>,
334            tag: impl Fn(A) -> ChartContextAction,
335        ) -> Vec<MenuEntry<ChartContextAction>> {
336            entries.into_iter().map(|entry| entry.map(&tag)).collect()
337        }
338        let mut entries = match context.target {
339            RightClickTarget::Background { .. } => tagged(
340                self.chart.build_menu_items(ctx, shortcuts),
341                ChartContextAction::Chart,
342            ),
343            RightClickTarget::Element {
344                id: ChartElementId::Indicator(id),
345                ..
346            } => tagged(self.study.build_menu_items(ctx, shortcuts), |action| {
347                ChartContextAction::Study { id, action }
348            }),
349            RightClickTarget::Element { .. } => tagged(
350                self.series.build_menu_items(ctx, shortcuts),
351                ChartContextAction::Series,
352            ),
353            RightClickTarget::Drawing { .. } => tagged(
354                self.drawing.build_menu_items(ctx, shortcuts),
355                ChartContextAction::Drawing,
356            ),
357        };
358        if let Some(tpo) = &self.tpo {
359            let mut profile = tagged(tpo.entries(), ChartContextAction::Chart);
360            if !entries.is_empty() {
361                profile.push(MenuEntry::Separator);
362            }
363            profile.extend(entries);
364            entries = profile;
365        }
366        if let Some(profile) = &self.trade_profile {
367            let mut profile = tagged(profile.entries(), ChartContextAction::Chart);
368            if !entries.is_empty() {
369                profile.push(MenuEntry::Separator);
370            }
371            profile.extend(entries);
372            entries = profile;
373        }
374        let mut separated = false;
375        for entry in &self.host_entries {
376            if !(entry.condition)(&context) {
377                continue;
378            }
379            if !separated {
380                if !entries.is_empty() {
381                    entries.push(MenuEntry::Separator);
382                }
383                separated = true;
384            }
385            let mut item = MenuItem::new(
386                &entry.label,
387                ChartContextAction::Host {
388                    id: entry.id.clone(),
389                    context,
390                },
391            );
392            item.icon = entry.icon;
393            entries.push(MenuEntry::Item(item));
394        }
395        entries
396    }
397}
398
399#[cfg(test)]
400mod tests;