1mod 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#[derive(Debug, Clone, PartialEq)]
37#[non_exhaustive]
38pub enum ContextMenuAction {
39 None,
41 CopyPrice(f64),
43 AddAlert {
45 symbol: String,
47 price: f64,
49 },
50 AddToWatchlist {
53 list: usize,
55 symbol: String,
57 },
58 Order(OrderRequest),
61 ObjectTree,
63 RemoveAllIndicators,
65 OpenSettings,
67 OpenSeriesSettings,
69 OpenDataTable,
71 ExportCsv,
73 ResetChart,
75 MergeTpoPrevious,
77 MergeTpoNext,
79 SplitTpoProfile,
81 MergeTradeProfilePrevious,
83 MergeTradeProfileNext,
85 SplitTradeProfile,
87 DrawHorizontalLine(f64),
90}
91
92pub(crate) fn format_price(price: f64) -> String {
94 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 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
142pub struct ChartContextMenu {
151 is_open: bool,
152 position: Pos2,
153 pub symbol: String,
155 pub click_price: f64,
157 pub indicator_cnt: usize,
159 pub market_price: Option<f64>,
162 pub tick_size: Option<f64>,
164 pub watchlists: Vec<String>,
167 pub order_quantity: f64,
169 pub order_types: Capabilities,
171 pub last_action: ContextMenuAction,
173 just_opened: bool,
175 pub(crate) hidden: HashSet<Control>,
177 pub(crate) horizontal_line: bool,
179}
180
181impl Default for ChartContextMenu {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187impl ChartContextMenu {
188 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 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; }
220
221 pub fn close(&mut self) {
223 self.is_open = false;
224 }
225
226 pub fn is_open(&self) -> bool {
228 self.is_open
229 }
230
231 pub fn set_symbol(&mut self, symbol: impl Into<String>) {
233 self.symbol = symbol.into();
234 }
235
236 pub fn set_indicator_cnt(&mut self, count: usize) {
238 self.indicator_cnt = count;
239 }
240
241 pub fn take_action(&mut self) -> ContextMenuAction {
243 std::mem::replace(&mut self.last_action, ContextMenuAction::None)
244 }
245
246 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 MenuEntry::Item(
258 MenuItem::new("Reset chart view", ContextMenuAction::ResetChart)
259 .with_shortcut(shortcuts.shortcut(KeyboardAction::ResetChart)),
260 ),
261 MenuEntry::Separator,
262 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 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 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 MenuEntry::Item(MenuItem::new("Object Tree…", ContextMenuAction::ObjectTree)),
394 MenuEntry::Separator,
395 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 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 MenuEntry::Item(
427 MenuItem::new("Series Settings…", ContextMenuAction::OpenSeriesSettings)
428 .with_icon(&embedded_icons::CANDLESTICK),
429 ),
430 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 pub fn show(&mut self, ctx: &Context, theme: &Theme) {
461 self.show_with_shortcuts(ctx, theme, &crate::widget::ShortcutMap::default());
462 }
463
464 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 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 #[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 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 #[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}