1mod quotes;
4mod study_controls;
5
6pub(crate) use study_controls::study_controls;
7
8use quotes::paint_quotes;
9
10use crate::chart::rendering::StyleColors;
11use crate::chart::rendering::axes::semibold;
12use crate::localization as l10n;
13use crate::model::session::MarketSession;
14use crate::model::{Bar, DataStatus, Instrument, Level1Quote, percent_change};
15use crate::painting::symbol_mark::paint_symbol_mark;
16use crate::scales::price_decimals;
17use crate::studies::Indicator;
18use crate::theme::ThemeExt;
19use egui::{Color32, FontId, ImageSource, Pos2, Rect, Sense, Stroke, Ui, Vec2};
20use std::sync::Arc;
21
22pub struct LegendStatus<'a> {
24 pub text: &'a str,
26 pub color: Color32,
28 pub background: Color32,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum QuoteSide {
35 Sell,
37 Buy,
39 Ticket,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq)]
45#[non_exhaustive]
46pub enum StudyAction {
47 Settings(usize),
49 ToggleVisibility(usize),
51 Remove(usize),
53 Menu {
56 index: usize,
58 pos: Pos2,
60 },
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65#[cfg_attr(not(feature = "icons"), allow(dead_code))]
66pub(crate) enum SeriesAction {
67 Settings,
69 ToggleVisibility,
71 Remove,
73}
74
75pub struct Legend<'a> {
77 pub formatters: super::ValueFormatters<'a>,
79 pub symbol: &'a str,
81 pub timeframe: &'a str,
83 pub instrument: Option<&'a Instrument>,
85 pub bar: &'a Bar,
87 pub previous_close: Option<f64>,
89 pub quote: Option<&'a Level1Quote>,
91 pub badge: Option<ImageSource<'a>>,
93 pub show_logo: bool,
96 pub show_market_status: bool,
99 pub(crate) session: Option<MarketSession>,
102 pub statuses: &'a [LegendStatus<'a>],
104 pub studies: &'a [Box<dyn Indicator>],
106 pub study_inputs: &'a [&'a str],
108 pub bar_index: usize,
110 pub show_volume: bool,
112 pub show_title: bool,
114 pub(crate) series_visible: bool,
117 pub title_as_description: bool,
119 pub show_ohlc: bool,
121 pub show_change: bool,
123 pub show_change_percent: bool,
125 pub show_indicator_titles: bool,
127 pub show_indicator_inputs: bool,
129 pub show_indicator_values: bool,
131 pub day_change: Option<(f64, f64)>,
134 pub study_backdrop: f32,
137 pub(crate) quantity: Option<f64>,
140 pub(crate) thousands_sep: Option<char>,
143}
144
145impl<'a> Legend<'a> {
146 pub fn new(symbol: &'a str, timeframe: &'a str, bar: &'a Bar) -> Self {
148 Self {
149 formatters: Default::default(),
150 symbol,
151 timeframe,
152 instrument: None,
153 bar,
154 previous_close: None,
155 quote: None,
156 badge: None,
157 show_logo: true,
158 show_market_status: true,
159 session: None,
160 statuses: &[],
161 studies: &[],
162 study_inputs: &[],
163 bar_index: 0,
164 show_volume: true,
165 show_title: true,
166 series_visible: true,
167 title_as_description: true,
168 show_ohlc: true,
169 show_change: true,
170 show_change_percent: true,
171 show_indicator_titles: true,
172 show_indicator_inputs: true,
173 show_indicator_values: true,
174 day_change: None,
175 study_backdrop: 0.6,
176 quantity: None,
177 thousands_sep: None,
178 }
179 }
180
181 pub fn show(&self, ui: &mut Ui, plot: Rect) -> Option<QuoteSide> {
185 self.show_with_study_actions(ui, plot).0
186 }
187
188 pub fn show_with_study_actions(
191 &self,
192 ui: &mut Ui,
193 plot: Rect,
194 ) -> (Option<QuoteSide>, Option<StudyAction>) {
195 let theme = ui.active_theme();
196 let colors = StyleColors {
197 bullish: theme.chart.candle_up,
198 bearish: theme.chart.candle_down,
199 text: theme.ui.text,
200 bullish_border: None,
201 bearish_border: None,
202 bullish_wick: None,
203 bearish_wick: None,
204 candle_border_width: 0.0,
205 };
206 let (quote, study, ..) = self.show_with_colors(ui, plot, &colors);
207 (quote, study)
208 }
209
210 pub(crate) fn show_with_colors(
212 &self,
213 ui: &mut Ui,
214 plot: Rect,
215 colors: &StyleColors,
216 ) -> (
217 Option<QuoteSide>,
218 Option<StudyAction>,
219 f32,
220 Option<Rect>,
221 Option<SeriesAction>,
222 ) {
223 let theme = ui.active_theme();
224 let bounds = plot.shrink2(Vec2::new(theme.spacing.lg, theme.spacing.md));
225 if !bounds.is_positive() {
226 return (None, None, plot.top(), None, None);
227 }
228 #[cfg_attr(not(feature = "icons"), allow(unused_mut))]
229 let mut series_action = None;
230 let id = ui.next_auto_id().with(("series_legend", self.symbol));
231 ui.skip_ahead_auto_ids(1);
232 let painter = ui.painter().with_clip_rect(plot);
233 let mut layout = Layout::new(&painter, bounds, colors);
234 #[cfg(feature = "icons")]
235 let title_start = layout.pos;
236 if self.show_title {
237 let image = self.badge.clone().or_else(|| {
238 self.instrument
239 .and_then(|instrument| instrument.logo.as_deref())
240 .map(ImageSource::from)
241 });
242 let mark_symbol = self
243 .instrument
244 .map_or(self.symbol, |instrument| instrument.symbol.as_str());
245 if self.show_logo && (image.is_some() || mark_symbol.chars().any(char::is_alphanumeric))
246 {
247 let rect = layout.reserve(Vec2::splat(18.0));
248 layout.pos.x = rect.right() + 5.0;
250 paint_symbol_mark(&painter, rect, mark_symbol, image);
251 }
252 let title = self.title();
253 let small = FontId::proportional(theme.text.small);
254 let mut pill: Vec<(StatusGlyph, &'static str, Color32, f32)> = Vec::new();
256 if self.show_market_status {
257 if let Some(session) = self.session {
258 let (label, color) = session_status(session, &theme);
259 pill.push((StatusGlyph::Session(session), label, color, 20.0));
260 }
261 let data = match self
262 .instrument
263 .and_then(|instrument| instrument.data_status)
264 {
265 Some(DataStatus::Delayed) => Some(("D", "Delayed data")),
266 Some(DataStatus::EndOfDay) => Some(("E", "End-of-day data")),
267 _ => None,
268 };
269 if let Some((text, label)) = data {
270 let galley = painter.layout_no_wrap(
271 l10n::text(ui.ctx(), text),
272 small.clone(),
273 theme.ui.warning,
274 );
275 let width = (galley.size().x + 8.0).max(20.0);
276 pill.push((StatusGlyph::Text(galley), label, theme.ui.warning, width));
277 }
278 }
279 let pill_width: f32 = pill.iter().map(|(.., width)| width).sum();
280 let host_width = self
281 .statuses
282 .iter()
283 .map(|status| {
284 painter
285 .layout_no_wrap(status.text.to_owned(), small.clone(), status.color)
286 .size()
287 .x
288 + 16.0
289 })
290 .sum::<f32>();
291 let status_width = if pill.is_empty() {
292 0.0
293 } else {
294 pill_width + 8.0
295 } + host_width;
296 let title_width = (bounds.right() - layout.pos.x - status_width - 64.0).max(0.0);
297 layout.text(
298 title,
299 theme.text.chart_title,
300 if self.series_visible {
301 theme.ui.text
302 } else {
303 theme.ui.text_muted
304 },
305 title_width,
306 );
307 if !pill.is_empty() {
308 let pill_rect = layout.reserve(Vec2::new(pill_width, 18.0));
309 let (mut left, last) = (pill_rect.left(), pill.len() - 1);
310 for (index, (glyph, label, color, width)) in pill.into_iter().enumerate() {
311 let rect = Rect::from_min_size(
312 Pos2::new(left, pill_rect.top()),
313 Vec2::new(width, 18.0),
314 );
315 left += width;
316 let fill = theme.chart.bg.lerp_to_gamma(color, 0.15);
318 let mut corners = egui::CornerRadius::from(theme.radius.sm);
319 if index > 0 {
320 (corners.nw, corners.sw) = (0, 0);
321 }
322 if index < last {
323 (corners.ne, corners.se) = (0, 0);
324 }
325 painter.rect_filled(rect, corners, fill);
326 paint_status_glyph(&painter, rect.center(), glyph, color, fill);
327 let name = l10n::text(ui.ctx(), label);
328 let response = ui
329 .interact(
330 rect.intersect(bounds),
331 id.with(("market_status", index)),
332 Sense::hover(),
333 )
334 .on_hover_text(name.clone());
335 response
336 .widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, &name));
337 }
338 }
339 for status in self.statuses {
340 let galley =
341 painter.layout_no_wrap(status.text.to_owned(), small.clone(), status.color);
342 let rect = layout.reserve(Vec2::new(galley.size().x + 8.0, 18.0));
343 painter.rect_filled(rect, theme.radius.sm, status.background);
344 painter.galley(rect.center() - galley.size() / 2.0, galley, status.color);
345 }
346 }
347 let precision = self.price_precision(self.bar.close);
348 if self.show_ohlc {
349 if plot.width() < 600.0 {
350 layout.field(
351 "C ",
352 self.formatters.price.map_or_else(
353 || {
354 crate::scales::group_thousands(
355 format!("{:.precision$}", self.bar.close),
356 self.thousands_sep,
357 )
358 },
359 |formatter| formatter.format(self.bar.close),
360 ),
361 layout.bar_color(self.bar),
362 );
363 } else {
364 layout.ohlc(
365 self.bar,
366 precision,
367 self.formatters.price,
368 self.thousands_sep,
369 );
370 }
371 }
372 let change_text = |reference: f64, value: f64| {
374 let change = value - reference;
375 let pct = percent_change(reference, value);
376 let absolute = self.formatters.price.map_or_else(
377 || {
378 crate::scales::group_thousands(
379 format!("{change:+.precision$}"),
380 self.thousands_sep,
381 )
382 },
383 |formatter| formatter.format(change),
384 );
385 let percent = self
386 .formatters
387 .percentage
388 .map_or_else(|| format!("{pct:+.2}%"), |formatter| formatter.format(pct));
389 (change, absolute, percent)
390 };
391 if self.show_change || self.show_change_percent {
392 let reference = self.previous_close.unwrap_or(self.bar.open);
393 let (change, absolute, percent) = change_text(reference, self.bar.close);
394 let text = match (self.show_change, self.show_change_percent) {
395 (true, true) => format!("{absolute} ({percent})"),
396 (true, false) => absolute,
397 _ => percent,
398 };
399 layout.text(
400 text,
401 theme.text.ohlc,
402 colors.bar_color(change >= 0.0),
403 bounds.width(),
404 );
405 }
406 if let Some((reference, last)) = self.day_change {
407 let (change, absolute, percent) = change_text(reference, last);
408 layout.text(
409 format!("{absolute} ({percent})"),
410 theme.text.ohlc,
411 colors.bar_color(change >= 0.0),
412 bounds.width(),
413 );
414 }
415 #[cfg(feature = "icons")]
417 if self.show_title {
418 let size = Vec2::new(116.0, 20.0);
419 let controls = if layout.pos.x + size.x > bounds.right() {
421 Rect::from_min_size(
422 Pos2::new(
423 bounds.right() - size.x,
424 layout.pos.y + (layout.height - size.y) / 2.0,
425 ),
426 size,
427 )
428 } else {
429 layout.reserve(size)
430 };
431 let row_right = if layout.pos.y > title_start.y {
432 bounds.right()
433 } else {
434 controls.right().min(bounds.right())
435 };
436 let title_rect =
437 Rect::from_min_max(title_start, Pos2::new(row_right, controls.bottom()));
438 let hovered = ui.rect_contains_pointer(title_rect);
439 let flagged = ui
440 .ctx()
441 .data(|data| data.get_temp::<bool>(id.with("flag")).unwrap_or(false));
442 let title_response = ui.interact(
445 title_rect,
446 id.with("title"),
447 Sense::focusable_noninteractive(),
448 );
449 let controls_label = l10n::text(ui.ctx(), "Series controls");
450 title_response.widget_info(|| {
451 egui::WidgetInfo::labeled(egui::Role::Button, true, &controls_label)
452 });
453 let focused = title_response.has_focus()
454 || title_response.lost_focus()
455 || ui.memory(|memory| {
456 memory.has_focus(id.with("flag"))
457 || memory.has_focus(id.with("more"))
458 || (0..3_usize)
459 .any(|index| memory.has_focus(id.with(("series_control", index))))
460 });
461 if hovered
462 || flagged
463 || focused
464 || egui::Popup::is_id_open(ui.ctx(), id.with("more").with("popup"))
465 {
466 let flag = Rect::from_min_size(controls.min, Vec2::splat(20.0)).intersect(bounds);
467 let more =
468 Rect::from_min_size(controls.min + Vec2::new(28.0, 0.0), Vec2::splat(20.0))
469 .intersect(bounds);
470 painter.rect_stroke(
471 title_rect.union(controls.expand2(Vec2::new(0.0, 2.0))),
472 theme.radius.sm,
473 Stroke::new(1.0, theme.ui.accent),
474 egui::StrokeKind::Inside,
475 );
476 painter.rect_filled(controls, 0.0, theme.chart.bg);
477 let flag_response = ui.interact(flag, id.with("flag"), Sense::click());
478 crate::icons::icons::FLAG_MARK
479 .as_image_tinted(
480 Vec2::splat(16.0),
481 if flagged {
482 theme.ui.accent
483 } else {
484 theme.ui.text
485 },
486 )
487 .paint_at(ui, flag.shrink(2.0));
488 if flag_response
489 .on_hover_text(l10n::text(ui.ctx(), "Flag instrument"))
490 .clicked()
491 {
492 ui.ctx()
493 .data_mut(|data| data.insert_temp(id.with("flag"), !flagged));
494 }
495 let more_response = ui
496 .interact(more, id.with("more"), Sense::click())
497 .on_hover_text(l10n::text(ui.ctx(), "Series actions"));
498 crate::icons::icons::UI_MORE_HORIZONTAL
499 .as_image_tinted(Vec2::splat(16.0), theme.ui.text)
500 .paint_at(ui, more.shrink(2.0));
501 egui::Popup::menu(&more_response).show(|ui| {
502 if ui.button(l10n::text(ui.ctx(), "Copy symbol")).clicked() {
503 ui.ctx().copy_text(self.symbol.to_owned());
504 ui.close();
505 }
506 if ui.button(l10n::text(ui.ctx(), "Copy values")).clicked() {
507 let values = [
508 self.bar.open,
509 self.bar.high,
510 self.bar.low,
511 self.bar.close,
512 self.bar.volume,
513 ];
514 ui.ctx().copy_text(l10n::format_text(
515 ui.ctx(),
516 "Open {0} High {1} Low {2} Close {3} Vol {4}",
517 &[
518 ("0", values[0].to_string()),
519 ("1", values[1].to_string()),
520 ("2", values[2].to_string()),
521 ("3", values[3].to_string()),
522 ("4", values[4].to_string()),
523 ],
524 ));
525 ui.close();
526 }
527 });
528 for (index, (action, icon, label)) in [
529 (
530 SeriesAction::ToggleVisibility,
531 if self.series_visible {
532 &crate::icons::icons::EYE
533 } else {
534 &crate::icons::icons::EYE_HIDE
535 },
536 if self.series_visible {
537 "Hide series"
538 } else {
539 "Show series"
540 },
541 ),
542 (
543 SeriesAction::Settings,
544 &crate::icons::icons::SETTINGS,
545 "Series settings",
546 ),
547 (
548 SeriesAction::Remove,
549 &crate::icons::icons::TRASH,
550 "Remove series",
551 ),
552 ]
553 .into_iter()
554 .enumerate()
555 {
556 let rect = Rect::from_min_size(
557 controls.min + Vec2::new(56.0 + index as f32 * 20.0, 0.0),
558 Vec2::splat(20.0),
559 )
560 .intersect(bounds);
561 let label = l10n::text(ui.ctx(), label);
562 let response = ui
563 .interact(rect, id.with(("series_control", index)), Sense::click())
564 .on_hover_text(&label);
565 response.widget_info(|| {
566 egui::WidgetInfo::labeled(egui::Role::Button, true, &label)
567 });
568 if response.hovered() {
569 painter.rect_filled(rect, theme.radius.sm, theme.ui.btn_bg_hover);
570 ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
571 }
572 icon.as_image_tinted(Vec2::splat(14.0), theme.ui.text)
573 .paint_at(ui, rect.shrink(3.0));
574 if response.clicked() {
575 series_action = Some(action);
576 }
577 }
578 }
579 }
580 if self.show_title
581 || self.show_ohlc
582 || self.show_change
583 || self.show_change_percent
584 || self.day_change.is_some()
585 {
586 layout.new_row();
587 }
588
589 let (action, quantity) = self.quote.map_or((None, None), |quote| {
590 let (bid, ask) = (quote.bid, quote.ask);
591 if !bid.is_finite() || !ask.is_finite() || bid > ask || !quote.spread().is_finite() {
592 return (None, None);
593 }
594 let precision = self.price_precision(bid.abs().max(ask.abs()));
595 let action = paint_quotes(
596 ui,
597 &mut layout,
598 bid,
599 ask,
600 precision,
601 self.thousands_sep,
602 self.quantity,
603 id,
604 );
605 layout.new_row();
606 layout.pos.y += 4.0;
607 action
608 });
609
610 layout.backdrop = theme
611 .chart
612 .bg
613 .gamma_multiply(self.study_backdrop.clamp(0.0, 1.0));
614 let mut study_settings = None;
615 let collapsed = ui
616 .ctx()
617 .data_mut(|data| data.get_temp::<bool>(id).unwrap_or(false));
618 let has_rows = self.show_volume || self.studies.iter().any(|study| study.is_overlay());
619 if !collapsed {
620 if self.show_volume
621 && !self
622 .studies
623 .iter()
624 .any(|study| study.is_volume() && study.is_overlay())
625 {
626 layout.field(
627 &format!("{} ", l10n::text(ui.ctx(), "Vol")),
628 self.formatters
629 .volume
630 .map(|formatter| formatter.format(self.bar.volume))
631 .unwrap_or_else(|| {
632 self.instrument
633 .and_then(|instrument| instrument.volume_decimals)
634 .map_or_else(
635 || format_volume(self.bar.volume),
636 |decimals| {
637 crate::scales::PriceFormatter::format(
638 &crate::scales::VolumeFormatter {
639 precision: usize::from(decimals),
640 },
641 self.bar.volume,
642 )
643 },
644 )
645 }),
646 layout.bar_color(self.bar),
647 );
648 layout.new_row();
649 layout.pos.y += 4.0;
650 }
651 for (index, study) in self
652 .studies
653 .iter()
654 .enumerate()
655 .filter(|(_, study)| study.is_overlay())
656 {
657 if study.is_volume() && !self.show_volume {
658 continue;
659 }
660 let row_start = layout.pos;
661 if self.show_indicator_titles {
662 layout.text(
663 if study.is_volume() {
664 l10n::text(ui.ctx(), "Vol")
665 } else {
666 study.name().to_owned()
667 },
668 theme.text.ohlc,
669 colors.text,
670 bounds.width(),
671 );
672 }
673 if study.is_volume() {
674 if self.show_indicator_inputs && study.plot_style(2).visible {
675 layout.text(
676 study.line_names().get(2).cloned().unwrap_or_default(),
677 theme.text.ohlc,
678 theme.ui.text_muted,
679 bounds.width(),
680 );
681 }
682 if self.show_indicator_values && study.is_visible() {
683 let up = study.values().value(0, self.bar_index);
684 let line = usize::from(up.is_none());
685 if let Some(value) = up.or_else(|| study.values().value(1, self.bar_index))
686 && study.plot_style(line).visible
687 {
688 let [r, g, b, _] = study
690 .colors()
691 .get(line)
692 .map_or(colors.bar_color(up.is_some()), |color| *color)
693 .to_srgba_unmultiplied();
694 layout.text(
695 study.format_value(value),
696 theme.text.ohlc,
697 Color32::from_rgb(r, g, b),
698 bounds.width(),
699 );
700 }
701 }
702 } else {
703 if self.show_indicator_inputs
704 && let Some(inputs) = self
705 .study_inputs
706 .get(index)
707 .filter(|inputs| !inputs.is_empty())
708 {
709 layout.text(
710 (*inputs).to_owned(),
711 theme.text.ohlc,
712 theme.ui.text_muted,
713 bounds.width(),
714 );
715 }
716 let study_colors = study.colors();
717 let names = study.line_names();
718 let precision = study.precision().or_else(|| {
719 (study.value_format() == crate::scales::StudyFormat::Price)
720 .then_some(precision)
721 });
722 for index in (0..study.line_count()).filter(|index| {
723 self.show_indicator_values
724 && study.is_visible()
725 && study.legend_visible(*index)
726 }) {
727 let value = study.plot_value(index, self.bar_index).unwrap_or(f64::NAN);
728 let text = if value.is_finite() {
729 if study.value_format() == crate::scales::StudyFormat::Price {
730 crate::scales::group_thousands(
731 study.value_format().format(value, precision),
732 self.thousands_sep,
733 )
734 } else {
735 study.format_plot_value(index, value)
736 }
737 } else {
738 "∅".to_owned()
739 };
740 let color = study_colors
742 .get(index)
743 .filter(|color| color.a() != 0)
744 .map_or(colors.text, |color| color.to_opaque());
745 let text = if study.line_count() > 1 {
746 format!("{}: {text}", names.get(index).map_or("", String::as_str))
747 } else {
748 text
749 };
750 layout.text(text, theme.text.ohlc, color, bounds.width());
751 }
752 }
753 let controls = layout.reserve(Vec2::new(80.0, 20.0)).intersect(bounds);
754 let row_right = if layout.pos.y > row_start.y {
755 bounds.right()
756 } else {
757 controls.right()
758 };
759 let row = Rect::from_min_max(row_start, Pos2::new(row_right, controls.bottom()))
760 .intersect(bounds);
761 layout.new_row();
763 let (responses, menu) = study_controls(
764 ui,
765 row,
766 controls,
767 id.with(("study", index)),
768 study.is_visible(),
769 );
770 for (response, intent) in responses.iter().zip([
771 StudyAction::ToggleVisibility(index),
772 StudyAction::Settings(index),
773 StudyAction::Remove(index),
774 ]) {
775 if response.clicked() {
776 study_settings = Some(intent);
777 }
778 }
779 if let Some(pos) = menu {
780 study_settings = Some(StudyAction::Menu { index, pos });
781 }
782 }
783 }
784 if has_rows {
785 let rect = layout.reserve(Vec2::new(29.0, 21.0));
786 let response = ui.interact(rect.intersect(bounds), id, Sense::click());
787 let label = l10n::text(
788 ui.ctx(),
789 if collapsed {
790 "Expand legend"
791 } else {
792 "Collapse legend"
793 },
794 );
795 response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
796 painter.rect(
797 rect,
798 3.0,
799 painter.ctx().active_theme().chart.bg,
800 Stroke::new(
801 1.0,
802 if theme.dark {
803 theme.ui.border
804 } else {
805 theme.ui.panel_bg.lerp_to_gamma(theme.ui.text, 0.14)
806 },
807 ),
808 egui::StrokeKind::Inside,
809 );
810 let c = rect.center();
811 let dy = if collapsed { -2.0 } else { 2.0 };
812 painter.add(egui::Shape::line(
813 vec![
814 c + Vec2::new(-4.0, dy),
815 c + Vec2::new(0.0, -dy),
816 c + Vec2::new(4.0, dy),
817 ],
818 Stroke::new(1.5, colors.text),
819 ));
820 if response.has_focus() {
821 painter.rect_stroke(
822 rect,
823 theme.radius.sm,
824 ui.visuals().selection.stroke,
825 egui::StrokeKind::Inside,
826 );
827 }
828 if response.clicked() {
829 ui.ctx().data_mut(|data| data.insert_temp(id, !collapsed));
830 }
831 }
832 (
833 action,
834 study_settings,
835 layout.pos.y + layout.height,
836 quantity,
837 series_action,
838 )
839 }
840
841 fn title(&self) -> String {
842 let instrument = self.instrument;
843 [
844 if self.title_as_description {
845 instrument
846 .and_then(|i| i.description.as_deref())
847 .unwrap_or(self.symbol)
848 } else {
849 instrument.map_or(self.symbol, |i| i.symbol.as_str())
850 },
851 self.timeframe,
852 instrument
853 .and_then(|i| i.exchange.as_deref())
854 .unwrap_or_default(),
855 ]
856 .into_iter()
857 .filter(|text| !text.is_empty())
858 .collect::<Vec<_>>()
859 .join(" · ")
860 }
861
862 fn price_precision(&self, price: f64) -> usize {
863 self.instrument
864 .and_then(crate::scales::instrument_price_precision)
865 .map_or_else(|| price_decimals(price), usize::from)
866 }
867}
868
869enum StatusGlyph {
871 Session(MarketSession),
872 Text(Arc<egui::Galley>),
873}
874
875fn session_status(session: MarketSession, theme: &crate::theme::Theme) -> (&'static str, Color32) {
877 match session {
878 MarketSession::Open => ("Market open", theme.ui.success),
879 MarketSession::PreMarket => ("Pre-market", theme.ui.warning),
880 MarketSession::PostMarket => ("Post-market", theme.ui.info),
881 MarketSession::Closed => ("Market closed", theme.ui.text_muted),
882 }
883}
884
885fn paint_status_glyph(
887 painter: &egui::Painter,
888 center: Pos2,
889 glyph: StatusGlyph,
890 color: Color32,
891 fill: Color32,
892) {
893 match glyph {
894 StatusGlyph::Session(MarketSession::Open) => {
895 painter.circle_filled(center, 4.0, color);
896 }
897 StatusGlyph::Session(MarketSession::PreMarket) => {
898 painter.circle_filled(center, 2.0, color);
900 for ray in 0..8 {
901 let direction = Vec2::angled(ray as f32 * std::f32::consts::TAU / 8.0);
902 painter.line_segment(
903 [center + direction * 3.5, center + direction * 5.0],
904 Stroke::new(1.2, color),
905 );
906 }
907 }
908 StatusGlyph::Session(MarketSession::PostMarket) => {
909 painter.circle_filled(center, 4.0, color);
911 painter.circle_filled(center + Vec2::new(2.0, -1.5), 3.5, fill);
912 }
913 StatusGlyph::Session(MarketSession::Closed) => {
914 painter.circle_stroke(center, 3.5, Stroke::new(1.5, color));
915 }
916 StatusGlyph::Text(galley) => {
917 painter.galley(center - galley.size() / 2.0, galley, color);
918 }
919 }
920}
921
922struct Layout<'a> {
923 painter: &'a egui::Painter,
924 bounds: Rect,
925 pos: Pos2,
926 height: f32,
927 background: egui::layers::ShapeIdx,
928 backdrop: Color32,
930 colors: &'a StyleColors,
931}
932
933impl<'a> Layout<'a> {
934 fn new(painter: &'a egui::Painter, bounds: Rect, colors: &'a StyleColors) -> Self {
935 Self {
936 painter,
937 bounds,
938 pos: bounds.min,
939 height: 24.0,
940 background: painter.add(egui::Shape::Noop),
941 backdrop: painter.ctx().active_theme().chart.bg,
942 colors,
943 }
944 }
945
946 fn new_row(&mut self) {
947 self.pos = Pos2::new(self.bounds.left(), self.pos.y + self.height);
948 self.height = 22.0;
949 self.background = self.painter.add(egui::Shape::Noop);
950 }
951
952 fn reserve_space(&mut self, size: Vec2) -> Rect {
953 if self.pos.x > self.bounds.left() && self.pos.x + size.x > self.bounds.right() {
954 self.new_row();
955 }
956 self.height = self.height.max(size.y);
957 let rect = Rect::from_min_size(
958 Pos2::new(self.pos.x, self.pos.y + (self.height - size.y) / 2.0),
959 size,
960 );
961 self.pos.x += size.x + 8.0;
962 rect
963 }
964
965 fn reserve(&mut self, size: Vec2) -> Rect {
966 let rect = self.reserve_space(size);
967 self.painter.set(
968 self.background,
969 egui::Shape::rect_filled(
970 Rect::from_min_max(
971 Pos2::new(self.bounds.left() - 2.0, self.pos.y),
972 Pos2::new(rect.right() + 4.0, self.pos.y + self.height),
973 )
974 .intersect(self.bounds.expand(2.0)),
975 0.0,
976 self.backdrop,
977 ),
978 );
979 let background = self.painter.ctx().active_theme().chart.bg;
980 if self.backdrop != background {
981 self.painter.rect_filled(
983 rect.expand(2.0).intersect(self.bounds.expand(2.0)),
984 0.0,
985 background,
986 );
987 }
988 rect
989 }
990
991 fn galley(&mut self, galley: Arc<egui::Galley>) {
992 let rect = self.reserve(galley.size());
993 self.painter.galley(rect.min, galley, self.colors.text);
994 }
995
996 fn text(&mut self, text: String, size: f32, color: Color32, max_width: f32) {
997 if text.is_empty() {
998 return;
999 }
1000 let mut job =
1001 egui::text::LayoutJob::simple_singleline(text, FontId::proportional(size), color);
1002 job.wrap.max_width = max_width;
1003 job.wrap.max_rows = 1;
1004 self.galley(self.painter.layout_job(job));
1005 }
1006
1007 fn field(&mut self, label: &str, value: String, color: Color32) {
1008 let mut job = egui::text::LayoutJob::default();
1009 let font = FontId::proportional(self.painter.ctx().active_theme().text.ohlc);
1010 job.append(
1011 label,
1012 0.0,
1013 egui::text::TextFormat::simple(
1014 font.clone(),
1015 self.painter.ctx().active_theme().ui.text_secondary,
1016 ),
1017 );
1018 job.append(&value, 0.0, egui::text::TextFormat::simple(font, color));
1019 job.wrap.max_width = self.bounds.width();
1020 job.wrap.max_rows = 1;
1021 self.galley(self.painter.layout_job(job));
1022 }
1023
1024 fn bar_color(&self, bar: &Bar) -> Color32 {
1025 self.colors.bar_color(bar.close >= bar.open)
1026 }
1027
1028 fn ohlc(
1029 &mut self,
1030 bar: &Bar,
1031 precision: usize,
1032 formatter: Option<&dyn crate::scales::PriceFormatter>,
1033 thousands_sep: Option<char>,
1034 ) {
1035 let color = self.bar_color(bar);
1036 for (label, value) in [
1037 ("O ", bar.open),
1038 ("H ", bar.high),
1039 ("L ", bar.low),
1040 ("C ", bar.close),
1041 ] {
1042 self.field(
1043 label,
1044 formatter.map_or_else(
1045 || {
1046 crate::scales::group_thousands(
1047 format!("{value:.precision$}"),
1048 thousands_sep,
1049 )
1050 },
1051 |formatter| formatter.format(value),
1052 ),
1053 color,
1054 );
1055 }
1056 }
1057}
1058
1059fn format_volume(volume: f64) -> String {
1060 for (divisor, suffix) in [(1_000_000_000.0, "B"), (1_000_000.0, "M"), (1_000.0, "K")] {
1061 if volume.abs() >= divisor {
1062 return format!("{:.2}{suffix}", volume / divisor);
1063 }
1064 }
1065 format!("{volume:.0}")
1066}
1067
1068#[cfg(test)]
1069mod mark_tests {
1070 use super::*;
1071
1072 #[test]
1075 fn legend_names_the_market_state_and_hides_it_independently() {
1076 use crate::theme::contrast;
1077 use MarketSession::{Closed, Open, PostMarket, PreMarket};
1078 use egui::Shape;
1079 for (_, theme) in crate::theme::presets() {
1080 for (session, data, shown, labels) in [
1081 (Some(Open), None, true, &["Market open"][..]),
1082 (
1083 Some(PreMarket),
1084 Some(DataStatus::Delayed),
1085 true,
1086 &["Pre-market", "Delayed data"][..],
1087 ),
1088 (
1089 Some(PostMarket),
1090 Some(DataStatus::EndOfDay),
1091 true,
1092 &["Post-market", "End-of-day data"][..],
1093 ),
1094 (Some(Closed), None, true, &["Market closed"][..]),
1095 (Some(Open), Some(DataStatus::Delayed), false, &[][..]),
1096 (None, Some(DataStatus::Delayed), true, &["Delayed data"][..]),
1097 (None, Some(DataStatus::Realtime), true, &[][..]),
1098 ] {
1099 let ctx = egui::Context::default();
1100 crate::theme::apply_to_egui(&ctx, &theme);
1101 ctx.enable_accesskit();
1102 let mut instrument = Instrument::new("ABC");
1103 instrument.data_status = data;
1104 let bar = Bar::new(chrono::Utc::now(), 1.0, 2.0, 0.0, 1.0, 0.0);
1105 let host = [LegendStatus {
1106 text: "HALT",
1107 color: theme.ui.text,
1108 background: theme.ui.panel_bg_secondary,
1109 }];
1110 let mut output = ctx.run_ui(Default::default(), |ui| {
1111 let mut legend = Legend::new("ABC", "1h", &bar);
1112 legend.instrument = Some(&instrument);
1113 legend.session = session;
1114 legend.show_market_status = shown;
1115 legend.statuses = &host;
1116 legend.show(ui, Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0)));
1117 });
1118 output.textures_delta.clear();
1119 let update = output.platform_output.accesskit_update.as_ref().unwrap();
1120 let mut nodes: Vec<_> = update
1121 .nodes
1122 .iter()
1123 .map(|(_, node)| node)
1124 .filter(|node| node.role() == egui::Role::Image)
1125 .collect();
1126 nodes.sort_by(|a, b| a.bounds().unwrap().x0.total_cmp(&b.bounds().unwrap().x0));
1127 assert_eq!(
1128 nodes
1129 .iter()
1130 .map(|node| node.label().unwrap())
1131 .collect::<Vec<_>>(),
1132 labels
1133 );
1134 let title = output
1135 .shapes
1136 .iter()
1137 .find_map(|shape| match &shape.shape {
1138 Shape::Text(text) if text.galley.text() == "ABC · 1h" => Some(text),
1139 _ => None,
1140 })
1141 .unwrap();
1142 let mut left = title.pos.x + title.galley.size().x + 8.0;
1143 for (index, node) in nodes.iter().enumerate() {
1144 let bounds = node.bounds().unwrap();
1145 let rect = Rect::from_min_size(egui::pos2(left, 9.0), Vec2::new(20.0, 18.0));
1146 assert_eq!(
1147 (bounds.x0, bounds.y0, bounds.x1, bounds.y1),
1148 (
1149 rect.left() as f64,
1150 rect.top() as f64,
1151 rect.right() as f64,
1152 rect.bottom() as f64
1153 )
1154 );
1155 let fill = output
1156 .shapes
1157 .iter()
1158 .find_map(|shape| match &shape.shape {
1159 Shape::Rect(segment) if segment.rect == rect => Some(segment.fill),
1160 _ => None,
1161 })
1162 .expect("pill segment");
1163 let color = match labels[index] {
1164 "Market open" => theme.ui.success,
1165 "Pre-market" | "Delayed data" | "End-of-day data" => theme.ui.warning,
1166 "Post-market" => theme.ui.info,
1167 "Market closed" => theme.ui.text_muted,
1168 _ => unreachable!(),
1169 };
1170 let letter = labels[index].ends_with("data");
1171 assert!(contrast(color, fill) >= if letter { 4.5 } else { 3.0 });
1172 let glyph = output.shapes.iter().any(|shape| match &shape.shape {
1173 Shape::Circle(circle) if rect.contains(circle.center) => {
1174 if labels[index] == "Market closed" {
1175 circle.stroke.color == color && circle.stroke.width == 1.5
1176 } else {
1177 circle.fill == color
1178 && circle.radius
1179 == if labels[index] == "Pre-market" {
1180 2.0
1181 } else {
1182 4.0
1183 }
1184 }
1185 }
1186 Shape::Text(text) if letter => {
1187 text.fallback_color == color
1188 && rect.contains_rect(Rect::from_min_size(
1189 text.pos,
1190 text.galley.size(),
1191 ))
1192 }
1193 _ => false,
1194 });
1195 assert!(glyph, "{} glyph", labels[index]);
1196 left += 20.0;
1197 }
1198 for label in ["A", "HALT"] {
1199 assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, Shape::Text(text) if text.galley.text() == label)));
1200 }
1201 if !shown {
1202 assert!(!output.shapes.iter().any(|shape| matches!(&shape.shape, Shape::Text(text) if text.galley.text() == "D")));
1203 }
1204 output.drop_without_applying_deltas();
1205 }
1206 }
1207 }
1208
1209 #[test]
1212 fn legend_marks_the_symbol_before_its_title() {
1213 for (_, theme) in crate::theme::presets() {
1214 for (badge, logo, shown, title, symbol) in [
1215 (true, false, true, true, "ABC"),
1216 (true, true, true, true, "ABC"),
1217 (false, true, true, true, "ABC"),
1218 (false, false, true, true, "ABC"),
1219 (true, true, false, true, "ABC"),
1220 (false, false, true, false, "ABC"),
1221 (false, false, true, true, ""),
1222 ] {
1223 let ctx = egui::Context::default();
1224 crate::theme::apply_to_egui(&ctx, &theme);
1225 let texture = ctx.load_texture(
1226 "badge",
1227 egui::ColorImage::filled([18, 18], Color32::BLUE),
1228 Default::default(),
1229 );
1230 #[cfg(feature = "ui")]
1232 {
1233 egui_extras::install_image_loaders(&ctx);
1234 ctx.include_bytes("bytes://logo.svg", br#"<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"><circle cx="9" cy="9" r="9" fill="blue"/></svg>"#);
1235 }
1236 let logo_ready = logo && cfg!(feature = "ui");
1237 let mut instrument = Instrument::new(symbol);
1238 if logo {
1239 instrument = instrument.with_logo("bytes://logo.svg");
1240 }
1241 let bar = Bar::new(chrono::Utc::now(), 1.0, 2.0, 0.0, 1.0, 0.0);
1242 let mut output = ctx.run_ui(Default::default(), |ui| {
1243 let mut legend = Legend::new(symbol, "1h", &bar);
1244 legend.instrument = Some(&instrument);
1245 legend.badge = badge
1246 .then(|| ImageSource::Texture((texture.id(), Vec2::splat(18.0)).into()));
1247 legend.show_logo = shown;
1248 legend.show_title = title;
1249 legend.show(ui, Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0)));
1250 });
1251 output.textures_delta.clear();
1252 let mark = shown && title && (badge || !symbol.is_empty());
1253 let images: Vec<_> = output
1254 .shapes
1255 .iter()
1256 .filter_map(|shape| match &shape.shape {
1257 egui::Shape::Rect(rect)
1258 if rect.fill_texture_id() != egui::TextureId::default() =>
1259 {
1260 Some(rect.fill_texture_id() == texture.id())
1261 }
1262 _ => None,
1263 })
1264 .collect();
1265 let image = (mark && (badge || logo_ready)).then_some(badge);
1267 assert_eq!(images, Vec::from_iter(image));
1268 let letters = output
1269 .shapes
1270 .iter()
1271 .filter(|shape| {
1272 matches!(&shape.shape,
1273 egui::Shape::Text(text) if text.galley.text() == "A")
1274 })
1275 .count();
1276 assert_eq!(letters, usize::from(mark && !badge && !logo_ready));
1277 if title {
1278 let label = if symbol.is_empty() { "1h" } else { "ABC · 1h" };
1279 let text = output
1280 .shapes
1281 .iter()
1282 .find_map(|shape| match &shape.shape {
1283 egui::Shape::Text(text) if text.galley.text() == label => Some(text),
1284 _ => None,
1285 })
1286 .expect("title");
1287 assert_eq!(text.pos.x, theme.spacing.lg + if mark { 23.0 } else { 0.0 });
1288 }
1289 output.drop_without_applying_deltas();
1290 }
1291 }
1292 }
1293}
1294
1295#[cfg(all(test, feature = "studies"))]
1296mod tests {
1297 #[cfg(feature = "icons")]
1298 use super::*;
1299 #[cfg(feature = "icons")]
1300 use chrono::{TimeZone, Utc};
1301 #[cfg(feature = "icons")]
1302 use egui::{Context, Shape};
1303
1304 #[cfg(feature = "icons")]
1307 #[test]
1308 fn title_controls_report_series_actions_for_a_hidden_series_too() {
1309 use egui::{Event, Key};
1310 let bar = Bar::new(
1311 Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
1312 100.0,
1313 110.0,
1314 90.0,
1315 95.0,
1316 1.0,
1317 );
1318 let colors = StyleColors {
1319 bullish: Color32::GREEN,
1320 bearish: Color32::RED,
1321 text: Color32::WHITE,
1322 bullish_border: None,
1323 bearish_border: None,
1324 bullish_wick: None,
1325 bearish_wick: None,
1326 candle_border_width: 0.0,
1327 };
1328 for series_visible in [true, false] {
1329 let ctx = Context::default();
1330 let frame = |events| {
1331 let mut action = None;
1332 let output = ctx.run_ui(
1333 egui::RawInput {
1334 events,
1335 ..Default::default()
1336 },
1337 |ui| {
1338 let mut legend = Legend::new("SAMPLE", "1h", &bar);
1339 legend.series_visible = series_visible;
1340 let plot = Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0));
1341 action = legend.show_with_colors(ui, plot, &colors).4;
1342 },
1343 );
1344 let title = output.shapes.iter().any(|shape| {
1345 matches!(&shape.shape, Shape::Text(text) if text.galley.text() == "SAMPLE · 1h")
1346 });
1347 output.drop_without_applying_deltas();
1348 (action, title)
1349 };
1350 let key = |pressed| Event::Key {
1351 key: Key::Tab,
1352 physical_key: None,
1353 pressed,
1354 repeat: false,
1355 modifiers: Default::default(),
1356 };
1357 assert!(
1358 frame(vec![]).1,
1359 "the title stays while the series is hidden"
1360 );
1361 for stop in 0..3 {
1363 frame(vec![key(true)]);
1364 frame(vec![key(false)]);
1365 assert_eq!(frame(vec![]).0, None, "stop {stop}");
1366 }
1367 for expected in [
1368 SeriesAction::ToggleVisibility,
1369 SeriesAction::Settings,
1370 SeriesAction::Remove,
1371 ] {
1372 frame(vec![key(true)]);
1373 frame(vec![key(false)]);
1374 let enter = |pressed| Event::Key {
1375 key: Key::Enter,
1376 physical_key: None,
1377 pressed,
1378 repeat: false,
1379 modifiers: Default::default(),
1380 };
1381 let (action, _) = frame(vec![enter(true)]);
1382 frame(vec![enter(false)]);
1383 assert_eq!(action, Some(expected), "visible: {series_visible}");
1384 }
1385 }
1386 }
1387}
1388
1389#[cfg(test)]
1390mod layout_tests {
1391 use crate::model::Bar;
1392 use chrono::{TimeZone, Utc};
1393 use egui::{Context, Event, Pos2, Rect, Shape};
1394
1395 #[cfg(feature = "studies")]
1397 #[test]
1398 fn supplementary_rows_mask_profiles_over_their_content_and_controls() {
1399 use crate::chart::rendering::Legend;
1400 use crate::studies::{CustomIndicator, IndicatorValue};
1401 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1402 let ctx = Context::default();
1403 crate::theme::apply_to_egui(&ctx, &theme);
1404 let mut study =
1405 CustomIndicator::new("Profile readout", |_| vec![IndicatorValue::Single(120.0)])
1406 .with_overlay(true);
1407 crate::studies::Indicator::calculate(&mut study, &[]);
1408 let studies: Vec<Box<dyn crate::studies::Indicator>> = vec![Box::new(study)];
1409 let bar = Bar::new(Utc.timestamp_opt(0, 0).unwrap(), 1.0, 1.0, 1.0, 1.0, 240.0);
1410 let instrument = crate::model::Instrument::new("SAMPLE").with_price_decimals(2);
1411 let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
1412 let mut legend = Legend::new("SAMPLE", "1m", &bar);
1413 legend.studies = &studies;
1414 legend.instrument = Some(&instrument);
1415 legend.show(
1416 ui,
1417 Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)),
1418 );
1419 });
1420 output.textures_delta.clear();
1421 for (at, shape) in output.shapes.iter().enumerate() {
1422 if let Shape::Text(text) = &shape.shape {
1423 let bounds = Rect::from_min_size(text.pos, text.galley.size());
1424 assert!(output.shapes[..at].iter().any(|shape| matches!(&shape.shape,
1425 Shape::Rect(rect) if rect.fill == theme.chart.bg && rect.rect.contains_rect(bounds)
1426 )), "unprotected {}", text.galley.text());
1427 if text.galley.text().starts_with("Vol ")
1428 || text.galley.text() == "Profile readout"
1429 || text.galley.text() == "120.00"
1430 {
1431 assert!(output.shapes[..at].iter().any(|shape| matches!(&shape.shape,
1432 Shape::Rect(rect) if rect.fill == theme.chart.bg && rect.rect.contains_rect(bounds.expand(2.0))
1433 )), "unprotected text padding");
1434 }
1435 if text.galley.text() == "120.00" {
1436 let controls = Rect::from_min_size(
1437 Pos2::new(bounds.right() + 8.0, bounds.center().y - 10.0),
1438 egui::vec2(80.0, 20.0),
1439 );
1440 assert!(output.shapes.iter().any(|shape| matches!(&shape.shape,
1441 Shape::Rect(rect) if rect.fill == theme.chart.bg && rect.rect.contains_rect(controls)
1442 )), "unprotected study controls");
1443 }
1444 }
1445 }
1446 output.drop_without_applying_deltas();
1447 }
1448 }
1449
1450 #[cfg(feature = "studies")]
1451 #[test]
1452 fn overlay_values_keep_their_output_names_when_rows_wrap() {
1453 use crate::chart::rendering::Legend;
1454 use crate::studies::{CustomIndicator, Indicator, IndicatorValue};
1455 let mut study = CustomIndicator::new("Levels", |_| {
1456 vec![IndicatorValue::Multiple(vec![1.0, 2.0, 3.0])]
1457 })
1458 .with_overlay(true)
1459 .with_line_count(3)
1460 .with_line_names(vec![
1461 "Ask levels".into(),
1462 "Bid levels".into(),
1463 "Total levels".into(),
1464 ]);
1465 study.calculate(&[]);
1466 let studies: Vec<Box<dyn Indicator>> = vec![Box::new(study)];
1467 let ctx = Context::default();
1468 let bar = Bar::new(Utc.timestamp_opt(0, 0).unwrap(), 1.0, 1.0, 1.0, 1.0, 0.0);
1469 let instrument = crate::model::Instrument::new("SAMPLE").with_price_decimals(2);
1470 for width in [320.0, 800.0, 1440.0] {
1471 let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
1472 let mut legend = Legend::new("SAMPLE", "1m", &bar);
1473 legend.studies = &studies;
1474 legend.instrument = Some(&instrument);
1475 legend.show_indicator_inputs = false;
1476 legend.show(
1477 ui,
1478 Rect::from_min_size(Pos2::ZERO, egui::vec2(width, 400.0)),
1479 );
1480 });
1481 output.textures_delta.clear();
1482 let texts: Vec<_> = output
1483 .shapes
1484 .iter()
1485 .filter_map(|shape| {
1486 if let Shape::Text(text) = &shape.shape {
1487 Some(text)
1488 } else {
1489 None
1490 }
1491 })
1492 .collect();
1493 for (index, name) in ["Ask levels", "Bid levels", "Total levels"]
1494 .iter()
1495 .enumerate()
1496 {
1497 let expected = format!("{name}: {:.2}", (index + 1) as f64);
1498 let text = texts
1499 .iter()
1500 .find(|text| text.galley.text() == expected)
1501 .expect(&expected);
1502 assert!(text.pos.x + text.galley.size().x <= width);
1503 }
1504 output.drop_without_applying_deltas();
1505 }
1506 }
1507
1508 #[test]
1510 fn legend_fields_stay_inside_the_plot() {
1511 use crate::chart::rendering::{Legend, LegendStatus};
1512 use crate::theme::apply_to_egui;
1513 use egui::Vec2;
1514
1515 for (_, theme) in crate::theme::presets() {
1516 for width in [160.0, 300.0, 387.0, 400.0, 599.0, 600.0, 1200.0, 1440.0] {
1517 let ctx = Context::default();
1518 #[cfg(feature = "icons")]
1519 crate::icons::install_loader(&ctx);
1520 apply_to_egui(&ctx, &theme);
1521 let plot = Rect::from_min_size(Pos2::new(52.0, 40.0), Vec2::new(width, 400.0));
1522 let badge = ctx.load_texture(
1523 "instrument",
1524 egui::ColorImage::filled([18, 18], egui::Color32::BLUE),
1525 Default::default(),
1526 );
1527 let statuses = [LegendStatus {
1528 text: "S",
1529 color: theme.ui.text,
1530 background: theme.ui.panel_bg_secondary,
1531 }];
1532 let instrument = crate::model::Instrument::new("Sample equity fund")
1533 .with_data_status(crate::model::DataStatus::Delayed);
1534 let bar = Bar::new(
1535 Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
1536 749.37,
1537 751.09,
1538 749.13,
1539 750.74,
1540 275_540.0,
1541 );
1542 let frame = |events| {
1543 ctx.run_ui(
1544 egui::RawInput {
1545 events,
1546 ..Default::default()
1547 },
1548 |ui| {
1549 let mut legend = Legend::new("Sample equity fund", "1h", &bar);
1550 legend.previous_close = Some(749.42);
1551 legend.badge = Some(egui::ImageSource::Texture(
1552 (badge.id(), Vec2::splat(18.0)).into(),
1553 ));
1554 legend.session = Some(crate::model::session::MarketSession::PostMarket);
1555 legend.statuses = &statuses;
1556 legend.instrument = Some(&instrument);
1557 legend.show_volume = false;
1558 legend.show(ui, plot);
1559 },
1560 )
1561 };
1562 let mut output = frame(vec![Event::PointerGone]);
1563 output.textures_delta.clear();
1564 let text: Vec<_> = output
1565 .shapes
1566 .iter()
1567 .filter_map(|shape| match &shape.shape {
1568 Shape::Text(text) => Some(text),
1569 _ => None,
1570 })
1571 .collect();
1572 assert!(!text.is_empty());
1573 let marks: Vec<_> = output
1574 .shapes
1575 .iter()
1576 .filter_map(|shape| match &shape.shape {
1577 Shape::Rect(rect) if rect.fill_texture_id() == badge.id() => {
1578 Some(rect.rect)
1579 }
1580 _ => None,
1581 })
1582 .collect();
1583 assert_eq!(marks.len(), 1);
1584 let mark = marks[0];
1585 assert!(
1586 plot.shrink2(Vec2::new(theme.spacing.lg, theme.spacing.md))
1587 .contains_rect(mark)
1588 );
1589 for field in &text {
1590 assert!(!mark.intersects(Rect::from_min_size(field.pos, field.galley.size())));
1591 }
1592 for (index, field) in text.iter().enumerate() {
1593 let bounds = Rect::from_min_size(field.pos, field.galley.size());
1594 for other in &text[index + 1..] {
1595 assert!(
1596 !bounds.intersects(Rect::from_min_size(other.pos, other.galley.size()))
1597 );
1598 }
1599 assert!(output.shapes.iter().any(|shape| matches!(&shape.shape,
1600 Shape::Rect(rect) if rect.fill == theme.chart.bg && rect.rect.height() >= 22.0 && rect.rect.contains_rect(bounds)
1601 )), "missing row backdrop for {}", field.galley.text());
1602 assert!(
1603 bounds.right() <= plot.right() - 8.0 + 0.1,
1604 "{width}: {} at {bounds:?}",
1605 field.galley.text()
1606 );
1607 }
1608 for shape in &output.shapes {
1609 if let Shape::Rect(rect) = &shape.shape
1610 && rect.fill == theme.chart.bg
1611 && rect.rect.height() >= 22.0
1612 {
1613 assert!(
1614 text.iter().any(|field| rect
1615 .rect
1616 .intersects(Rect::from_min_size(field.pos, field.galley.size()))),
1617 "{width}: empty legend row at {:?}",
1618 rect.rect
1619 );
1620 }
1621 }
1622 for (index, field) in ["O 749.37", "H 751.09", "L 749.13", "C 750.74"]
1623 .into_iter()
1624 .enumerate()
1625 {
1626 assert_eq!(
1627 text.iter().any(|text| text.galley.text() == field),
1628 width >= 600.0 || index == 3,
1629 "{width}: field visibility for {field}"
1630 );
1631 }
1632 assert!(
1633 text.iter()
1634 .any(|text| text.galley.text() == "+1.32 (+0.18%)")
1635 );
1636 assert!(text.iter().any(|text| text.galley.text() == "D"
1637 && text.galley.job.sections[0].format.color == theme.ui.warning));
1638 let centres: Vec<_> = text
1639 .iter()
1640 .map(|text| text.pos.y + text.galley.size().y / 2.0)
1641 .collect();
1642 if width >= 1200.0 {
1643 assert!(centres.iter().all(|y| (*y - centres[0]).abs() < 0.1));
1644 } else if width <= 300.0 {
1645 assert!(centres.iter().any(|y| *y > centres[0]));
1646 }
1647 for field in text
1648 .iter()
1649 .filter(|text| text.galley.text().starts_with("O "))
1650 {
1651 let sections = &field.galley.job.sections;
1652 assert_eq!(sections[0].format.color, theme.ui.text_secondary);
1653 assert_eq!(sections[1].format.color, theme.chart.candle_up);
1654 }
1655 let idle: Vec<_> = text
1656 .iter()
1657 .map(|text| (text.galley.text().to_owned(), text.pos, text.galley.size()))
1658 .collect();
1659 let hovered = frame(vec![Event::PointerMoved(plot.min + Vec2::new(60.0, 20.0))]);
1660 let hovered_text: Vec<_> = hovered
1661 .shapes
1662 .iter()
1663 .filter_map(|shape| match &shape.shape {
1664 Shape::Text(text) => {
1665 Some((text.galley.text().to_owned(), text.pos, text.galley.size()))
1666 }
1667 _ => None,
1668 })
1669 .collect();
1670 hovered.drop_without_applying_deltas();
1671 output.drop_without_applying_deltas();
1672 assert_eq!(idle, hovered_text, "hover moved readout at {width}");
1673 }
1674 }
1675 }
1676}