Skip to main content

egui_charts/chart/rendering/legend/
mod.rs

1//! In-pane series readout and supplementary rows.
2
3mod 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
22/// A host-supplied status mark. Its text and colours carry no built-in market meaning.
23pub struct LegendStatus<'a> {
24    /// Short text shown beside the instrument title.
25    pub text: &'a str,
26    /// Status text colour.
27    pub color: Color32,
28    /// Status background colour.
29    pub background: Color32,
30}
31
32/// The quote control clicked by the user. The host decides how to handle the intent.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum QuoteSide {
35    /// Sell at the displayed bid.
36    Sell,
37    /// Buy at the displayed ask.
38    Buy,
39    /// Open the order ticket without placing an order.
40    Ticket,
41}
42
43/// An action on the zero-based study index in a legend's study slice.
44#[derive(Clone, Copy, Debug, PartialEq)]
45#[non_exhaustive]
46pub enum StudyAction {
47    /// Open the study's settings in the host.
48    Settings(usize),
49    /// Toggle study visibility.
50    ToggleVisibility(usize),
51    /// Remove the study.
52    Remove(usize),
53    /// Open the study's menu at `pos`: below the more-actions button, or at the pointer after a
54    /// secondary click on the row.
55    Menu {
56        /// Zero-based study index.
57        index: usize,
58        /// Screen position of the menu's corner.
59        pos: Pos2,
60    },
61}
62
63/// An action on the series a legend reads, from the controls of its title row.
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65#[cfg_attr(not(feature = "icons"), allow(dead_code))]
66pub(crate) enum SeriesAction {
67    /// Open the series' settings in the host.
68    Settings,
69    /// Show or hide the series.
70    ToggleVisibility,
71    /// Remove the series.
72    Remove,
73}
74
75/// An in-pane legend. Historical readout data and current quotes are supplied independently.
76pub struct Legend<'a> {
77    /// Host number formatting for this series' readouts.
78    pub formatters: super::ValueFormatters<'a>,
79    /// Fallback title when the instrument has no description.
80    pub symbol: &'a str,
81    /// Display interval.
82    pub timeframe: &'a str,
83    /// Description, exchange and price precision reported by the host.
84    pub instrument: Option<&'a Instrument>,
85    /// The bar selected by the crosshair, or the latest bar when none is selected.
86    pub bar: &'a Bar,
87    /// Close of the bar preceding `bar`, including when it is outside the viewport.
88    pub previous_close: Option<f64>,
89    /// Current host quote, independent of `bar`. Invalid or crossed quotes are omitted.
90    pub quote: Option<&'a Level1Quote>,
91    /// Image for the symbol mark before the title, in place of the instrument's logo.
92    pub badge: Option<ImageSource<'a>>,
93    /// Show the symbol mark before the title: [`badge`](Self::badge), else the instrument's
94    /// [`logo`](Instrument::logo), else the symbol's first letter or digit on a neutral disc.
95    pub show_logo: bool,
96    /// Show the market status after the title: the session's state, when known, and delayed or
97    /// end-of-day data.
98    pub show_market_status: bool,
99    /// State of the instrument's session at the chart's clock. Only a chart knows its clock, so a
100    /// standalone legend shows the data status alone.
101    pub(crate) session: Option<MarketSession>,
102    /// Optional host-owned status marks.
103    pub statuses: &'a [LegendStatus<'a>],
104    /// Studies on the chart. Overlay studies retain a row while hidden so they can be shown again.
105    pub studies: &'a [Box<dyn Indicator>],
106    /// Display inputs supplied by the host, in `studies` order. Missing entries are omitted.
107    pub study_inputs: &'a [&'a str],
108    /// Global index of `bar` in each study's values.
109    pub bar_index: usize,
110    /// Whether this series has volume.
111    pub show_volume: bool,
112    /// Display the title row: the symbol mark, the instrument title and the status marks.
113    pub show_title: bool,
114    /// Whether the series is shown. A hidden series keeps its title and controls, dimmed, so it
115    /// can be shown again.
116    pub(crate) series_visible: bool,
117    /// Prefer the host description over the ticker.
118    pub title_as_description: bool,
119    /// Display OHLC fields, or the close alone in a compact plot.
120    pub show_ohlc: bool,
121    /// Display absolute bar change.
122    pub show_change: bool,
123    /// Display percentage bar change.
124    pub show_change_percent: bool,
125    /// Display study names.
126    pub show_indicator_titles: bool,
127    /// Display supplied study inputs.
128    pub show_indicator_inputs: bool,
129    /// Display study outputs.
130    pub show_indicator_values: bool,
131    /// The previous session's close and the last price, shown after the bar change as the
132    /// change between them.
133    pub day_change: Option<(f64, f64)>,
134    /// Opacity from 0 to 1 of the chart background behind the volume and study rows.
135    /// Text and controls retain opaque backing so profiles cannot interfere with them.
136    pub study_backdrop: f32,
137    /// The order quantity shown in a box under the quote buttons' spread, which the user clicks
138    /// to change it.
139    pub(crate) quantity: Option<f64>,
140    /// Groups the thousands of built-in price values; the chart sets it from
141    /// `ChartConfig::thousands_sep`.
142    pub(crate) thousands_sep: Option<char>,
143}
144
145impl<'a> Legend<'a> {
146    /// A series readout with volume and no optional host metadata or quotes.
147    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    /// Paints inside `plot`, inset by the theme's large spacing from its sides and medium
182    /// spacing from its top and bottom, and reports a quote intent.
183    /// Supplementary volume and study rows collapse together; quotes remain visible.
184    pub fn show(&self, ui: &mut Ui, plot: Rect) -> Option<QuoteSide> {
185        self.show_with_study_actions(ui, plot).0
186    }
187
188    /// Paint the legend and report quote and study intents independently.
189    /// Each study intent identifies its zero-based index in [`studies`](Self::studies).
190    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    /// Also returns the bottom of the legend and the quantity box, when the user clicked it.
211    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                // The title follows 5 points after the mark, closer than the layout's gap.
249                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            // The market status pill: the session's state, then delayed or end-of-day data.
255            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                    // Opaque, so each glyph's contrast is fixed whatever is drawn beneath.
317                    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        // The change from `reference` to `value`, absolute and as a percentage.
373        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        // Values follow the title directly; the controls take the space after them on hover.
416        #[cfg(feature = "icons")]
417        if self.show_title {
418            let size = Vec2::new(116.0, 20.0);
419            // On a full row the controls cover its end instead of opening an empty row.
420            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            // Tab reaches the controls through the title, which stays revealed while one of
443            // them has focus. It takes focus only, so clicks on it still reach the chart.
444            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                            // The column colour without its transparency.
689                            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                        // A fully transparent colour holds no RGB, so it has no opaque form.
741                        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                // The next row's backdrop goes under this row's outline, which reaches into it.
762                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
869/// A glyph of the market status pill.
870enum StatusGlyph {
871    Session(MarketSession),
872    Text(Arc<egui::Galley>),
873}
874
875/// The tooltip and accessible name of a session state, and the colour of its glyph.
876fn 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
885/// Paints `glyph` centred on `center` in `color`, over a segment filled with `fill`.
886fn 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            // A rising sun: a core and eight short rays.
899            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            // A crescent: a disc with an offset disc of the segment's fill cut out of it.
910            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    /// Fill of the current row's background.
929    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            // Profiles can be opaque or contain letters; protect the full content bounds.
982            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    /// Session and data glyphs have distinct shapes, readable contrast and accessible names.
1073    /// The status toggle leaves host marks and the logo independent.
1074    #[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    /// The mark uses the host override, then the instrument logo, then an initial, and the
1210    /// title keeps its five-point gap. Hiding the title or logo removes the mark.
1211    #[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                // Without image loaders the instrument's logo stays unreadable.
1231                #[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                // One image, the badge's when supplied, else the instrument's ready logo.
1266                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    /// The title's hide, settings and remove buttons are keyboard reachable and report their
1305    /// action, and a hidden series keeps its title and buttons so it can be shown again.
1306    #[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            // Tab stops: title, flag, more, then the series buttons.
1362            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    /// Profiles painted first cannot bleed through volume, study text or reserved controls.
1396    #[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    /// Legend fields stay inside the plot and keep their positions during hover.
1509    #[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}