Skip to main content

egui_charts/ui/context_menu/
drawing.rs

1//! Drawing context menu - right-click menu when drawing is selected.
2//!
3//! Provides actions for alerts, editing, layering, panes, locking, and deleting drawings.
4
5use crate::config::KeyboardAction;
6use crate::icons::icons as embedded_icons;
7use crate::localization as l10n;
8use crate::model::{Alert, AlertComparison, AlertCondition};
9use crate::theme::Theme;
10use crate::ui::context_menu::{MenuEntry, MenuItem, show_menu};
11use crate::widget::{Chart, PaneId};
12
13use egui::{Context, Id, Pos2};
14
15/// Distance between the pointer and the menu corner, enough to clear the handle under the
16/// pointer: half the 8 px handle plus 2 px.
17// shortcut: assumes the default handle size, upgrade when the hit result carries the handle rect.
18pub(crate) const DRAWING_MENU_OFFSET: f32 = 6.0;
19
20/// The panes of `chart` other than the one drawing `drawing_id`, the price pane last, by their
21/// stable ids and titles.
22pub(crate) fn move_targets(
23    ctx: Option<&Context>,
24    chart: &Chart,
25    drawing_id: usize,
26) -> Vec<(PaneId, String)> {
27    let current = chart.drawing_pane(drawing_id);
28    let mut panes = super::study::other_panes(ctx, chart, current);
29    if current.is_some_and(|pane| pane != PaneId::MAIN) {
30        panes.push((
31            PaneId::MAIN,
32            ctx.map_or_else(
33                || "Main pane".to_owned(),
34                |ctx| l10n::text(ctx, "Main pane"),
35            ),
36        ));
37    }
38    panes
39}
40
41/// Actions from the drawing context menu
42#[derive(Debug, Clone, PartialEq)]
43#[non_exhaustive]
44pub enum DrawingContextMenuAction {
45    /// No action requested.
46    None,
47    /// Open the alert dialog on this alert, the price crossing the drawing's first visible level, for
48    /// example with [`AlertDialog::edit`](crate::ui::dialogs::AlertDialog::edit),
49    /// listing the levels of [`Drawing::alert_levels_with_bars`](crate::drawings::Drawing::alert_levels_with_bars)
50    /// as its sources.
51    AddAlert(Box<Alert>),
52    /// Open drawing settings dialog
53    Edit,
54    /// Bring drawing to front of render order
55    BringToFront,
56    /// Move drawing above its next neighbour in render order.
57    BringForward,
58    /// Send drawing to back of render order
59    SendToBack,
60    /// Move drawing below its previous neighbour in render order.
61    SendBackward,
62    /// Copy the selected drawings to the clipboard.
63    Copy,
64    /// Move the drawing to this pane, for example with [`Chart::move_drawing_to_pane`].
65    MoveTo(PaneId),
66    /// Toggle drawing lock state
67    Lock,
68    /// Toggle drawing visibility
69    Hide,
70    /// Clone drawing with offset
71    Clone,
72    /// Delete drawing
73    Delete,
74}
75
76/// Drawing context menu state
77#[derive(Clone, Debug)]
78pub struct DrawingContextMenu {
79    is_open: bool,
80    position: Pos2,
81    /// Drawing ID that was right-clicked
82    pub drawing_id: Option<usize>,
83    /// Drawing type name for display
84    pub drawing_type: String,
85    /// Whether the drawing is currently locked
86    pub is_locked: bool,
87    /// Whether the drawing is currently visible
88    pub is_visible: bool,
89    /// The alert the Add alert entry offers; `None` leaves the entry out.
90    pub alert: Option<Alert>,
91    /// Panes the Move to submenu offers, by stable id and title; empty leaves it out.
92    pub panes: Vec<(PaneId, String)>,
93    /// Last action taken
94    pub last_action: DrawingContextMenuAction,
95    /// Skip close detection on first frame
96    just_opened: bool,
97}
98
99impl Default for DrawingContextMenu {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl DrawingContextMenu {
106    /// Create new context menu
107    pub fn new() -> Self {
108        Self {
109            is_open: false,
110            position: Pos2::ZERO,
111            drawing_id: None,
112            drawing_type: "Drawing".to_string(),
113            is_locked: false,
114            is_visible: true,
115            alert: None,
116            panes: Vec::new(),
117            last_action: DrawingContextMenuAction::None,
118            just_opened: false,
119        }
120    }
121
122    /// Open the context menu
123    pub fn open(&mut self, pos: Pos2, drawing_id: usize) {
124        self.is_open = true;
125        self.position = pos;
126        self.drawing_id = Some(drawing_id);
127        self.last_action = DrawingContextMenuAction::None;
128        self.just_opened = true;
129    }
130
131    /// Set drawing context for dynamic menu items
132    pub fn set_context(&mut self, drawing_type: impl Into<String>, locked: bool, visible: bool) {
133        self.drawing_type = drawing_type.into();
134        self.is_locked = locked;
135        self.is_visible = visible;
136    }
137
138    /// Open the menu on drawing `drawing_id` of `chart`, offering an alert when the chart has a
139    /// symbol and the drawing has price levels, and the chart's other panes.
140    pub(crate) fn open_on(&mut self, ctx: &Context, chart: &Chart, pos: Pos2, drawing_id: usize) {
141        let drawing = chart
142            .drawings()
143            .drawings
144            .iter()
145            .find(|d| d.id == drawing_id);
146        if let Some(drawing) = drawing {
147            self.set_context(drawing.tool_type.as_str(), drawing.locked, drawing.visible);
148        }
149        self.alert = drawing
150            .filter(|_| !chart.symbol.is_empty())
151            .and_then(|drawing| {
152                let (level, _, _) = drawing
153                    .alert_levels_with_bars(
154                        chart
155                            .bars(crate::chart::series::SeriesId::MAIN)
156                            .len()
157                            .saturating_sub(1) as f64,
158                        chart.config().right_axis_scale_mode,
159                        chart.bars(crate::chart::series::SeriesId::MAIN),
160                    )
161                    .into_iter()
162                    .next()?;
163                Some(Alert::new(
164                    chart.symbol.clone(),
165                    AlertCondition::Compare {
166                        other: drawing.alert_source(level),
167                        comparison: AlertComparison::Crossing,
168                    },
169                ))
170            });
171        self.panes = move_targets(Some(ctx), chart, drawing_id);
172        self.open(pos, drawing_id);
173    }
174
175    /// Close the context menu
176    pub fn close(&mut self) {
177        self.is_open = false;
178    }
179
180    /// Check if menu is open
181    pub fn is_open(&self) -> bool {
182        self.is_open
183    }
184
185    /// Take and consume the last action
186    pub fn take_action(&mut self) -> DrawingContextMenuAction {
187        std::mem::replace(&mut self.last_action, DrawingContextMenuAction::None)
188    }
189
190    /// Build menu items for the drawing context menu
191    pub(crate) fn build_menu_items(
192        &self,
193        ctx: &Context,
194        shortcuts: &crate::widget::ShortcutMap,
195    ) -> Vec<MenuEntry<DrawingContextMenuAction>> {
196        let alert = self.alert.iter().flat_map(|alert| {
197            [
198                MenuEntry::Item(
199                    MenuItem::new(
200                        l10n::format_text(
201                            ctx,
202                            "Add alert on {name}…",
203                            &[("name", l10n::text(ctx, &self.drawing_type).as_str())],
204                        ),
205                        DrawingContextMenuAction::AddAlert(Box::new(alert.clone())),
206                    )
207                    .with_icon(&embedded_icons::ALERTS),
208                ),
209                MenuEntry::Separator,
210            ]
211        });
212        alert
213            .chain([
214                // Edit section
215                MenuEntry::Item(
216                    MenuItem::new("Edit…", DrawingContextMenuAction::Edit)
217                        .with_icon(&embedded_icons::PENCIL),
218                ),
219                MenuEntry::Separator,
220                // Layer ordering
221                MenuEntry::Item(MenuItem::new(
222                    "Bring to front",
223                    DrawingContextMenuAction::BringToFront,
224                )),
225                MenuEntry::Item(MenuItem::new(
226                    "Bring forward",
227                    DrawingContextMenuAction::BringForward,
228                )),
229                MenuEntry::Item(MenuItem::new(
230                    "Send to back",
231                    DrawingContextMenuAction::SendToBack,
232                )),
233                MenuEntry::Item(MenuItem::new(
234                    "Send backward",
235                    DrawingContextMenuAction::SendBackward,
236                )),
237            ])
238            .chain((!self.panes.is_empty()).then(|| {
239                MenuEntry::Submenu {
240                    label: "Move to".into(),
241                    icon: Some(&embedded_icons::MOVE_PANE),
242                    entries: self
243                        .panes
244                        .iter()
245                        .map(|(pane, title)| {
246                            MenuEntry::Item(MenuItem::new(
247                                title,
248                                DrawingContextMenuAction::MoveTo(*pane),
249                            ))
250                        })
251                        .collect(),
252                }
253            }))
254            .chain([
255                MenuEntry::Separator,
256                // State toggles
257                MenuEntry::Item(
258                    MenuItem::new(
259                        if self.is_locked { "Unlock" } else { "Lock" },
260                        DrawingContextMenuAction::Lock,
261                    )
262                    .with_icon(if self.is_locked {
263                        &embedded_icons::UNLOCK
264                    } else {
265                        &embedded_icons::LOCK
266                    }),
267                ),
268                MenuEntry::Item(
269                    MenuItem::new(
270                        if self.is_visible { "Hide" } else { "Show" },
271                        DrawingContextMenuAction::Hide,
272                    )
273                    .with_icon(if self.is_visible {
274                        &embedded_icons::EYE_HIDE
275                    } else {
276                        &embedded_icons::EYE
277                    }),
278                ),
279                MenuEntry::Separator,
280                MenuEntry::Item(
281                    MenuItem::new("Copy", DrawingContextMenuAction::Copy)
282                        .with_shortcut(shortcuts.shortcut(KeyboardAction::CopyDrawing)),
283                ),
284                MenuEntry::Item(MenuItem::new("Clone", DrawingContextMenuAction::Clone)),
285                MenuEntry::Separator,
286                // Delete (last item, potentially destructive)
287                MenuEntry::Item(
288                    MenuItem::new("Delete", DrawingContextMenuAction::Delete)
289                        .with_icon(&embedded_icons::TRASH)
290                        .with_shortcut(shortcuts.shortcut(KeyboardAction::DeleteSelectedDrawing))
291                        .danger(),
292                ),
293            ])
294            .collect()
295    }
296
297    /// Show the context menu
298    pub fn show(&mut self, ctx: &Context, theme: &Theme) {
299        self.show_with_shortcuts(ctx, theme, &crate::widget::ShortcutMap::default());
300    }
301
302    /// Show menu hints from the same bindings used by chart input.
303    pub fn show_with_shortcuts(
304        &mut self,
305        ctx: &Context,
306        theme: &Theme,
307        shortcuts: &crate::widget::ShortcutMap,
308    ) {
309        if !self.is_open {
310            return;
311        }
312        let entries = self.build_menu_items(ctx, shortcuts);
313        let just_opened = std::mem::take(&mut self.just_opened);
314        let id = Id::unique("drawing_context_menu");
315        if let Some(action) = show_menu(
316            ctx,
317            id,
318            theme,
319            self.position,
320            DRAWING_MENU_OFFSET,
321            &entries,
322            just_opened,
323        ) {
324            self.last_action = action.unwrap_or(DrawingContextMenuAction::None);
325            self.close();
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::drawings::{ChartPoint, Drawing, DrawingToolType, FibonacciConfig};
334    use crate::model::{AlertSource, BarData};
335
336    /// The initial alert follows the first visible level, including when level zero is hidden.
337    #[test]
338    fn alerts_start_on_a_visible_drawing_level() {
339        let mut chart = Chart::new(BarData::default());
340        chart.symbol = "SAMPLE".into();
341        let mut drawing = Drawing::new(7, DrawingToolType::FibonacciRetracement);
342        drawing.chart_points = vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(10.0, 200.0)];
343        drawing.stored_id = Some("fib".into());
344        chart.drawings_mut().drawings.push(drawing);
345        let mut menu = DrawingContextMenu::new();
346        for visible in [vec![0, 1], vec![1], vec![]] {
347            let mut config = FibonacciConfig::default();
348            for (index, level) in config.levels.iter_mut().enumerate() {
349                level.visible = visible.contains(&index);
350            }
351            chart.drawings_mut().drawings[0].fib_config = Some(config);
352            menu.open_on(&Context::default(), &chart, Pos2::ZERO, 7);
353            assert_eq!(
354                menu.alert.as_ref().map(|alert| &alert.condition),
355                visible
356                    .first()
357                    .map(|level| AlertCondition::Compare {
358                        other: AlertSource::Drawing(if *level == 0 {
359                            "fib".into()
360                        } else {
361                            "fib#1".into()
362                        }),
363                        comparison: AlertComparison::Crossing,
364                    })
365                    .as_ref()
366            );
367        }
368    }
369}