egui_charts/ui/context_menu/chart_menus/
mod.rs1use 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#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct HostMenuContext {
19 pub target: RightClickTarget,
21 pub time: Option<chrono::DateTime<chrono::Utc>>,
23 pub price: f64,
25}
26
27pub struct HostMenuEntry {
32 pub id: String,
34 pub label: String,
36 pub icon: Option<&'static Icon>,
38 condition: Box<dyn Fn(&HostMenuContext) -> bool>,
39}
40
41impl HostMenuEntry {
42 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 pub fn with_icon(mut self, icon: &'static Icon) -> Self {
54 self.icon = Some(icon);
55 self
56 }
57
58 pub fn when(mut self, condition: impl Fn(&HostMenuContext) -> bool + 'static) -> Self {
60 self.condition = Box::new(condition);
61 self
62 }
63}
64
65#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub enum ChartContextAction {
75 Host {
77 id: String,
79 context: HostMenuContext,
81 },
82 Chart(ContextMenuAction),
84 Series(crate::ui::dialogs::SeriesContextMenuAction),
86 Study {
88 id: SeriesId,
90 action: crate::ui::dialogs::StudyContextMenuAction,
92 },
93 Drawing(crate::ui::dialogs::DrawingContextMenuAction),
95}
96
97#[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 pub host_entries: Vec<HostMenuEntry>,
123 context: Option<HostMenuContext>,
124 just_opened: bool,
125}
126
127impl ChartMenus {
128 pub fn series_click_price(&self) -> f64 {
130 self.series.click_price
131 }
132
133 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 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 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 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 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 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;