Skip to main content

egui_charts/ui/context_menu/
mod.rs

1//! Chart context menu
2//!
3//! Right-click context menu for the chart area. Provides trading actions,
4//! clipboard operations, chart management, and navigation options.
5
6mod axis;
7mod chart_menus;
8mod menu;
9mod tpo;
10
11pub mod drawing;
12pub mod series;
13pub mod study;
14pub(super) mod trade_profile;
15
16pub use chart_menus::{ChartContextAction, ChartMenus, HostMenuContext, HostMenuEntry};
17pub use menu::MenuItem;
18pub(crate) use menu::{MenuEntry, POINTER_GAP, show_menu, trim_separators};
19
20use std::collections::HashSet;
21
22use crate::config::KeyboardAction;
23use crate::icons::icons as embedded_icons;
24use crate::localization as l10n;
25use crate::model::{Capabilities, OrderRequest, OrderSide, OrderType};
26use crate::theme::Theme;
27use crate::ui::Control;
28
29use egui::{Context, Id, Pos2, Response};
30
31// ============================================================================
32// CONTEXT MENU ACTIONS
33// ============================================================================
34
35/// Action triggered by selecting a context menu item
36#[derive(Debug, Clone, PartialEq)]
37#[non_exhaustive]
38pub enum ContextMenuAction {
39    /// No action
40    None,
41    /// Copy price value to clipboard
42    CopyPrice(f64),
43    /// Add a price alert on a symbol
44    AddAlert {
45        /// Instrument identifier used by the host.
46        symbol: String,
47        /// Price under the pointer when the menu opened.
48        price: f64,
49    },
50    /// Add the symbol to the watchlist at this index of the panel's lists, with
51    /// [`WatchlistPanel::add_symbol_to`](crate::ui::widget_bar::WatchlistPanel::add_symbol_to).
52    AddToWatchlist {
53        /// Index into the lists given to [`ChartMenus::set_watchlists`].
54        list: usize,
55        /// Instrument identifier used by the host.
56        symbol: String,
57    },
58    /// A limit or stop order at the clicked price, for the user to confirm in the order ticket
59    /// ([`Trading::open_ticket`](crate::ui::trading::Trading::open_ticket)).
60    Order(OrderRequest),
61    /// Open the object tree panel
62    ObjectTree,
63    /// Remove all indicators from the chart
64    RemoveAllIndicators,
65    /// Open chart settings dialog
66    OpenSettings,
67    /// Open series settings dialog
68    OpenSeriesSettings,
69    /// Open the chart data table
70    OpenDataTable,
71    /// Export chart data to CSV
72    ExportCsv,
73    /// Reset the chart view to defaults
74    ResetChart,
75    /// Merge the clicked TPO profile with its previous neighbour. Already applied by [`ChartMenus`].
76    MergeTpoPrevious,
77    /// Merge the clicked TPO profile with its next neighbour. Already applied by [`ChartMenus`].
78    MergeTpoNext,
79    /// Restore the sessions of the clicked composite TPO profile. Already applied by [`ChartMenus`].
80    SplitTpoProfile,
81    /// Merge the clicked execution profile with its previous neighbour. Applied by [`ChartMenus`].
82    MergeTradeProfilePrevious,
83    /// Merge the clicked execution profile with its next neighbour. Applied by [`ChartMenus`].
84    MergeTradeProfileNext,
85    /// Split the clicked execution profile at the selected bar. Applied by [`ChartMenus`].
86    SplitTradeProfile,
87    /// Draw a horizontal line at this price in the clicked pane, with the line tool's default
88    /// style, and select it. Already applied by [`ChartMenus`].
89    DrawHorizontalLine(f64),
90}
91
92/// Format a price with two decimals (more below 1) and thousands separators.
93pub(crate) fn format_price(price: f64) -> String {
94    // Use 2 decimal places for most prices, but handle very small prices
95    let formatted = if price.abs() < 0.01 {
96        format!("{price:.6}")
97    } else if price.abs() < 1.0 {
98        format!("{price:.4}")
99    } else {
100        format!("{price:.2}")
101    };
102
103    // Add thousands separators
104    let parts: Vec<&str> = formatted.split('.').collect();
105    let integer_part = parts[0];
106    let decimal_part = parts.get(1).unwrap_or(&"");
107
108    let negative = integer_part.starts_with('-');
109    let digits: String = integer_part
110        .chars()
111        .filter(|c| c.is_ascii_digit())
112        .collect();
113
114    let with_commas: String = digits
115        .chars()
116        .rev()
117        .enumerate()
118        .fold(String::new(), |mut acc, (i, c)| {
119            if i > 0 && i % 3 == 0 {
120                acc.push(',');
121            }
122            acc.push(c);
123            acc
124        })
125        .chars()
126        .rev()
127        .collect();
128
129    if decimal_part.is_empty() {
130        if negative {
131            format!("-{with_commas}")
132        } else {
133            with_commas
134        }
135    } else if negative {
136        format!("-{with_commas}.{decimal_part}")
137    } else {
138        format!("{with_commas}.{decimal_part}")
139    }
140}
141
142// ============================================================================
143// CONTEXT MENU STATE
144// ============================================================================
145
146/// Chart context menu state and renderer
147///
148/// Manages the lifecycle of a right-click context menu including
149/// positioning, item rendering, and action dispatch.
150pub struct ChartContextMenu {
151    is_open: bool,
152    position: Pos2,
153    /// Current symbol for dynamic menu items
154    pub symbol: String,
155    /// Price at click position
156    pub click_price: f64,
157    /// Number of indicators on chart (for Remove menu)
158    pub indicator_cnt: usize,
159    /// The last price. Order entries need it: a click above it offers a sell limit and a buy
160    /// stop, a click below it a buy limit and a sell stop.
161    pub market_price: Option<f64>,
162    /// The symbol's tick size, to which order prices are rounded.
163    pub tick_size: Option<f64>,
164    /// Names of the watchlists the symbol can be added to; without any, the menu has no such
165    /// entry.
166    pub watchlists: Vec<String>,
167    /// The quantity of the order entries.
168    pub order_quantity: f64,
169    /// The order types the account accepts; entries for the others are left out.
170    pub order_types: Capabilities,
171    /// Last action taken
172    pub last_action: ContextMenuAction,
173    /// Skip close detection on first frame after opening
174    just_opened: bool,
175    /// Controls the host hid, set through [`ChartMenus::set_control_visible`].
176    pub(crate) hidden: HashSet<Control>,
177    /// Offer the horizontal line row; [`ChartMenus`] sets it from the drawing tool registry.
178    pub(crate) horizontal_line: bool,
179}
180
181impl Default for ChartContextMenu {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187impl ChartContextMenu {
188    /// Create a closed chart context menu with default options.
189    pub fn new() -> Self {
190        Self {
191            is_open: false,
192            position: Pos2::ZERO,
193            symbol: String::new(),
194            click_price: 0.0,
195            indicator_cnt: 0,
196            market_price: None,
197            tick_size: None,
198            watchlists: Vec::new(),
199            order_quantity: 1.0,
200            order_types: Capabilities {
201                limit: true,
202                stop: true,
203                ..Capabilities::default()
204            },
205            last_action: ContextMenuAction::None,
206            just_opened: false,
207            hidden: HashSet::new(),
208            horizontal_line: false,
209        }
210    }
211
212    /// Open the menu at `pos`, retaining the clicked `price` for its actions.
213    pub fn open(&mut self, pos: Pos2, price: f64) {
214        self.is_open = true;
215        self.position = pos;
216        self.click_price = price;
217        self.last_action = ContextMenuAction::None;
218        self.just_opened = true; // Skip close detection on first frame
219    }
220
221    /// Close the menu.
222    pub fn close(&mut self) {
223        self.is_open = false;
224    }
225
226    /// Whether the menu is currently visible.
227    pub fn is_open(&self) -> bool {
228        self.is_open
229    }
230
231    /// Set the current symbol for dynamic menu items
232    pub fn set_symbol(&mut self, symbol: impl Into<String>) {
233        self.symbol = symbol.into();
234    }
235
236    /// Set the indicator count
237    pub fn set_indicator_cnt(&mut self, count: usize) {
238        self.indicator_cnt = count;
239    }
240
241    /// Take and consume the last action (resets to None)
242    pub fn take_action(&mut self) -> ContextMenuAction {
243        std::mem::replace(&mut self.last_action, ContextMenuAction::None)
244    }
245
246    /// Build the menu items based on current state
247    fn build_menu_items(
248        &self,
249        ctx: &Context,
250        shortcuts: &crate::widget::ShortcutMap,
251    ) -> Vec<MenuEntry<ContextMenuAction>> {
252        let price_str = format_price(self.click_price);
253        let symbol = &self.symbol;
254
255        let mut items = vec![
256            // Reset chart view at top
257            MenuEntry::Item(
258                MenuItem::new("Reset chart view", ContextMenuAction::ResetChart)
259                    .with_shortcut(shortcuts.shortcut(KeyboardAction::ResetChart)),
260            ),
261            MenuEntry::Separator,
262            // Clipboard section
263            MenuEntry::Item(MenuItem::new(
264                l10n::format_text(ctx, "Copy price {price}", &[("price", price_str.as_str())]),
265                ContextMenuAction::CopyPrice(self.click_price),
266            )),
267            MenuEntry::Separator,
268        ];
269        // Trading items need an instrument, so they are offered only with a symbol, but the line row.
270        let mut trading = Vec::new();
271        if !symbol.is_empty() && !self.hidden.contains(&Control::Alerts) {
272            trading.push(MenuEntry::Item(
273                MenuItem::new(
274                    l10n::format_text(
275                        ctx,
276                        "Add alert on {name} at {price}…",
277                        &[("name", symbol.as_str()), ("price", price_str.as_str())],
278                    ),
279                    ContextMenuAction::AddAlert {
280                        symbol: symbol.clone(),
281                        price: self.click_price,
282                    },
283                )
284                .with_icon(&embedded_icons::ALERTS)
285                .with_shortcut(shortcuts.shortcut(KeyboardAction::CreateAlert)),
286            ));
287        }
288        if self.horizontal_line && self.click_price.is_finite() {
289            trading.push(MenuEntry::Item(
290                MenuItem::new(
291                    l10n::format_text(
292                        ctx,
293                        "Draw horizontal line at {price}",
294                        &[("price", price_str.as_str())],
295                    ),
296                    ContextMenuAction::DrawHorizontalLine(self.click_price),
297                )
298                .with_icon(&embedded_icons::HORIZONTAL_LINE)
299                .with_shortcut(shortcuts.shortcut(KeyboardAction::DrawingTool(
300                    crate::config::DrawingToolType::HorizontalLine,
301                ))),
302            ));
303        }
304        if !symbol.is_empty() && !self.watchlists.is_empty() {
305            trading.push(MenuEntry::Submenu {
306                label: l10n::format_text(
307                    ctx,
308                    "Add {symbol} to watchlist",
309                    &[("symbol", symbol.as_str())],
310                ),
311                icon: Some(&embedded_icons::WATCHLIST),
312                entries: self
313                    .watchlists
314                    .iter()
315                    .enumerate()
316                    .map(|(list, name)| {
317                        MenuEntry::Item(MenuItem::new(
318                            name.clone(),
319                            ContextMenuAction::AddToWatchlist {
320                                list,
321                                symbol: symbol.clone(),
322                            },
323                        ))
324                    })
325                    .collect(),
326            });
327        }
328        if !symbol.is_empty()
329            && !self.hidden.contains(&Control::Trading)
330            && self.order_quantity.is_finite()
331            && self.order_quantity > 0.0
332            && self.click_price.is_finite()
333            && let Some(market) = self.market_price.filter(|price| price.is_finite())
334        {
335            let price = self
336                .tick_size
337                .filter(|tick| tick.is_finite() && *tick > 0.0)
338                .map_or(self.click_price, |tick| {
339                    crate::scales::to_step(self.click_price, tick, f64::round)
340                });
341            // Above the market a sell waits as a limit order and a buy as a stop order; below it,
342            // the other way round. At the market either order can fill immediately.
343            let (limit, stop) = if price >= market {
344                (OrderSide::Sell, OrderSide::Buy)
345            } else {
346                (OrderSide::Buy, OrderSide::Sell)
347            };
348            for (side, order_type) in [
349                (limit, OrderType::Limit(price)),
350                (stop, OrderType::Stop(price)),
351            ] {
352                if !self.order_types.accepts(&order_type) {
353                    continue;
354                }
355                let request = OrderRequest::new(symbol, side, self.order_quantity, order_type);
356                let key = match (side, order_type) {
357                    (OrderSide::Buy, OrderType::Limit(_)) => {
358                        "Buy {quantity} {symbol} @ {price} limit"
359                    }
360                    (OrderSide::Buy, _) => "Buy {quantity} {symbol} @ {price} stop",
361                    (OrderSide::Sell, OrderType::Limit(_)) => {
362                        "Sell {quantity} {symbol} @ {price} limit"
363                    }
364                    (OrderSide::Sell, _) => "Sell {quantity} {symbol} @ {price} stop",
365                };
366                let icon = match side {
367                    OrderSide::Buy => &embedded_icons::LONG_POS,
368                    OrderSide::Sell => &embedded_icons::SHORT_POS,
369                };
370                trading.push(MenuEntry::Item(
371                    MenuItem::new(
372                        l10n::format_text(
373                            ctx,
374                            key,
375                            &[
376                                ("quantity", self.order_quantity.to_string().as_str()),
377                                ("symbol", symbol.as_str()),
378                                ("price", format_price(price).as_str()),
379                            ],
380                        ),
381                        ContextMenuAction::Order(request),
382                    )
383                    .with_icon(icon),
384                ));
385            }
386        }
387        if !trading.is_empty() {
388            items.extend(trading);
389            items.push(MenuEntry::Separator);
390        }
391        items.extend([
392            // View section
393            MenuEntry::Item(MenuItem::new("Object Tree…", ContextMenuAction::ObjectTree)),
394            MenuEntry::Separator,
395            // Indicator management
396            MenuEntry::Item(if self.indicator_cnt > 0 {
397                MenuItem::new(
398                    if self.indicator_cnt == 1 {
399                        l10n::text(ctx, "Remove 1 indicator")
400                    } else {
401                        l10n::format_text(
402                            ctx,
403                            "Remove {count} indicators",
404                            &[("count", self.indicator_cnt.to_string().as_str())],
405                        )
406                    },
407                    ContextMenuAction::RemoveAllIndicators,
408                )
409                .danger()
410            } else {
411                MenuItem::new("Remove indicators", ContextMenuAction::RemoveAllIndicators)
412                    .disabled()
413            }),
414            MenuEntry::Separator,
415            // Export data
416            MenuEntry::Item(MenuItem::new(
417                "View Data Table…",
418                ContextMenuAction::OpenDataTable,
419            )),
420            MenuEntry::Item(MenuItem::new(
421                "Export Data to CSV…",
422                ContextMenuAction::ExportCsv,
423            )),
424            MenuEntry::Separator,
425            // Series settings (candle colors, price source)
426            MenuEntry::Item(
427                MenuItem::new("Series Settings…", ContextMenuAction::OpenSeriesSettings)
428                    .with_icon(&embedded_icons::CANDLESTICK),
429            ),
430            // Settings - using existing Settings icon (hexagon)
431            MenuEntry::Item(
432                MenuItem::new("Chart Settings…", ContextMenuAction::OpenSettings)
433                    .with_icon(&embedded_icons::SETTINGS_GEAR),
434            ),
435        ]);
436        items.retain(|entry| {
437            let control = match entry {
438                MenuEntry::Item(item) => match item.action {
439                    ContextMenuAction::ResetChart => Control::ResetView,
440                    ContextMenuAction::CopyPrice(_) => Control::CopyPrice,
441                    ContextMenuAction::ObjectTree => Control::ObjectTree,
442                    ContextMenuAction::RemoveAllIndicators => Control::Indicators,
443                    ContextMenuAction::OpenDataTable | ContextMenuAction::ExportCsv => {
444                        Control::ExportData
445                    }
446                    ContextMenuAction::OpenSettings | ContextMenuAction::OpenSeriesSettings => {
447                        Control::Settings
448                    }
449                    _ => return true,
450                },
451                MenuEntry::Submenu { .. } | MenuEntry::Separator => return true,
452            };
453            !self.hidden.contains(&control)
454        });
455        trim_separators(&mut items);
456        items
457    }
458
459    /// Show the context menu
460    pub fn show(&mut self, ctx: &Context, theme: &Theme) {
461        self.show_with_shortcuts(ctx, theme, &crate::widget::ShortcutMap::default());
462    }
463
464    /// Show menu hints from the same bindings used by chart input.
465    pub fn show_with_shortcuts(
466        &mut self,
467        ctx: &Context,
468        theme: &Theme,
469        shortcuts: &crate::widget::ShortcutMap,
470    ) {
471        if !self.is_open {
472            return;
473        }
474        let entries = self.build_menu_items(ctx, shortcuts);
475        if entries.is_empty() {
476            self.close();
477            return;
478        }
479        let just_opened = std::mem::take(&mut self.just_opened);
480        let id = Id::unique("chart_ctx_menu");
481        if let Some(action) = show_menu(
482            ctx,
483            id,
484            theme,
485            self.position,
486            POINTER_GAP,
487            &entries,
488            just_opened,
489        ) {
490            self.last_action = action.unwrap_or(ContextMenuAction::None);
491            self.close();
492        }
493    }
494
495    /// Check if chart should show context menu on right-click
496    pub fn check_for_open(response: &Response, menu: &mut ChartContextMenu, price_at_pos: f64) {
497        if response.secondary_clicked()
498            && let Some(pos) = response.interact_pointer_pos()
499        {
500            menu.open(pos, price_at_pos);
501        }
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    /// Guards #218: entries above the last price offer a sell limit and a buy stop; below it, a
510    /// buy limit and a sell stop. They use the configured quantity at the click rounded to the
511    /// tick, and leave out order types the account does not accept. Fixed sides send a marketable
512    /// order from a click below the market.
513    #[test]
514    fn order_entries_follow_the_market() {
515        use OrderSide::{Buy, Sell};
516        use OrderType::{Limit, Stop};
517        let mut menu = ChartContextMenu::new();
518        menu.symbol = "AAPL".into();
519        menu.market_price = Some(100.0);
520        menu.tick_size = Some(0.25);
521        menu.order_quantity = 3.0;
522        let stop_only = Capabilities {
523            stop: true,
524            ..Capabilities::default()
525        };
526        // (click, accepted types) -> entries
527        for (click, types, expected) in [
528            (
529                101.1,
530                menu.order_types,
531                vec![(Sell, Limit(101.0)), (Buy, Stop(101.0))],
532            ),
533            (
534                100.0,
535                menu.order_types,
536                vec![(Sell, Limit(100.0)), (Buy, Stop(100.0))],
537            ),
538            (
539                98.9,
540                menu.order_types,
541                vec![(Buy, Limit(99.0)), (Sell, Stop(99.0))],
542            ),
543            (98.9, stop_only, vec![(Sell, Stop(99.0))]),
544            (f64::NAN, menu.order_types, vec![]),
545        ] {
546            menu.click_price = click;
547            menu.order_types = types;
548            let entries: Vec<_> = menu
549                .build_menu_items(&Context::default(), &crate::widget::ShortcutMap::default())
550                .into_iter()
551                .filter_map(|entry| match entry {
552                    MenuEntry::Item(MenuItem {
553                        action: ContextMenuAction::Order(order),
554                        ..
555                    }) => {
556                        assert_eq!((order.symbol.as_str(), order.quantity), ("AAPL", 3.0));
557                        Some((order.side, order.order_type))
558                    }
559                    _ => None,
560                })
561                .collect();
562            assert_eq!(entries, expected, "click at {click}");
563        }
564    }
565
566    #[test]
567    fn reset_row_shows_the_reset_chart_binding() {
568        let ctx = Context::default();
569        ctx.set_os(egui::os::OperatingSystem::Nix);
570        let shortcut = ChartContextMenu::new()
571            .build_menu_items(&Context::default(), &crate::widget::ShortcutMap::default())
572            .into_iter()
573            .find_map(|entry| match entry {
574                MenuEntry::Item(item) if item.action == ContextMenuAction::ResetChart => {
575                    item.shortcut
576                }
577                _ => None,
578            })
579            .expect("the Reset chart view row shows a shortcut");
580        assert_eq!(
581            KeyboardAction::from_key_press(shortcut.logical_key, &shortcut.modifiers),
582            Some(KeyboardAction::ResetChart)
583        );
584        assert_eq!(ctx.format_shortcut(&shortcut), "Alt+R");
585    }
586    /// Value rows must reach the lookup as templates, including rounded order prices.
587    #[test]
588    fn value_rows_reach_the_translator() {
589        use crate::model::{Alert, AlertCondition};
590        use crate::ui::dialogs::{DrawingContextMenu, SeriesContextMenu};
591        use egui::{RawInput, Rect, Shape, Vec2, pos2};
592
593        let mut chart = ChartContextMenu::new();
594        chart.symbol = "1301".into();
595        chart.watchlists = vec!["7".into()];
596        chart.market_price = Some(100.0);
597        chart.tick_size = Some(1.0);
598        chart.order_quantity = 2.0;
599        chart.indicator_cnt = 2;
600        chart.horizontal_line = true;
601        chart.open(pos2(100.0, 100.0), 101.1);
602        let mut series = SeriesContextMenu::new();
603        series.set_context("1301", 101.1);
604        series.open(pos2(100.0, 100.0), crate::chart::series::SeriesId::MAIN);
605        let mut drawing = DrawingContextMenu::new();
606        drawing.set_context("Trend Line", false, true);
607        drawing.alert = Some(Alert::new("1301", AlertCondition::Crossing(100.0)));
608        drawing.open(pos2(100.0, 100.0), 1);
609        let surfaces: [(&str, &[&str]); 3] = [
610            (
611                "chart",
612                &[
613                    "~101.10~",
614                    "~1301~101.10~",
615                    "~1301~",
616                    "~2~1301~101.00~",
617                    "~2~",
618                ],
619            ),
620            ("series", &["~1301~101.10~", "~1301~", "~101.10~"]),
621            ("drawing", &["~~~"]),
622        ];
623        fn collect(shape: &Shape, texts: &mut Vec<String>) {
624            match shape {
625                Shape::Text(text) => texts.push(text.galley.text().to_owned()),
626                Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, texts)),
627                _ => {}
628            }
629        }
630        for (name, expected) in surfaces {
631            let ctx = Context::default();
632            crate::localization::mark_keys(&ctx);
633            let mut frame = || {
634                ctx.run_ui(
635                    RawInput {
636                        screen_rect: Some(Rect::from_min_size(
637                            Pos2::ZERO,
638                            Vec2::new(1440.0, 900.0),
639                        )),
640                        ..Default::default()
641                    },
642                    |ui| match name {
643                        "chart" => chart.show(ui.ctx(), &Theme::dark()),
644                        "series" => series.show(ui.ctx(), &Theme::dark()),
645                        _ => drawing.show(ui.ctx(), &Theme::dark()),
646                    },
647                )
648            };
649            frame().drop_without_applying_deltas();
650            let output = frame();
651            let mut texts = Vec::new();
652            for clipped in &output.shapes {
653                collect(&clipped.shape, &mut texts);
654            }
655            output.drop_without_applying_deltas();
656            assert!(!texts.is_empty(), "{name}");
657            for text in &texts {
658                assert!(
659                    !text.chars().any(|c| c.is_ascii_alphabetic()),
660                    "{name}: {text}"
661                );
662            }
663            for expected in expected {
664                assert!(
665                    texts.iter().any(|text| text == *expected),
666                    "{name}: {expected}: {texts:?}"
667                );
668            }
669            if name == "chart" {
670                assert_eq!(
671                    texts
672                        .iter()
673                        .filter(|text| *text == "~2~1301~101.00~")
674                        .count(),
675                    2
676                );
677            }
678        }
679    }
680}