1use 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#[derive(Debug, Clone, PartialEq)]
24#[non_exhaustive]
25pub enum StudyContextMenuAction {
26 AddAlert(Box<Alert>),
30 AddIndicator,
32 AddToFavorites(String),
34 VisualOrder(VisualOrder),
36 Intervals(IntervalRange),
38 MoveTo(PaneTarget),
40 PinTo(StudyScale),
42 Copy,
44 ToggleVisibility,
46 Remove,
48 ObjectTree,
50 Settings,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum IntervalRange {
57 AndAbove,
59 AndBelow,
61 Only,
63 All,
65}
66
67impl IntervalRange {
68 pub fn hidden_intervals(self, current: Timeframe) -> Vec<Timeframe> {
70 let mut intervals = Timeframe::all();
71 if !intervals.contains(¤t) {
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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum PaneTarget {
87 NewPaneAbove,
89 NewPaneBelow,
91 Pane(PaneId),
93 Main,
95}
96
97pub(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#[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 axis_side: &'static str,
157 panes: Vec<(PaneId, String)>,
159 has_interval: bool,
160 next_scale: u64,
161 shared_scales: Vec<u64>,
162 pub(crate) hidden: HashSet<Control>,
165}
166
167impl StudyContextMenu {
168 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
415pub(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 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}