Skip to main content

egui_charts/widget/
format.rs

1use super::Chart;
2use crate::chart::rendering;
3use crate::scales::TimeFormatterBuilder;
4
5impl Chart {
6    /// Formats a timestamp with the chart's zone, clock visibility and interval, or the median
7    /// spacing of the source bars in or nearest the view for inferred and trade-based intervals.
8    /// On a [horizontal scale](Self::set_horizontal_scale), formats its key as the crosshair does.
9    /// Hosts can use this for data windows and other readouts beside the chart.
10    pub fn format_time(&self, time: chrono::DateTime<chrono::Utc>) -> String {
11        let bar_seconds = self.time_label_seconds();
12        match &self.horizontal_scale {
13            Some(scale) => scale.format_crosshair(time, bar_seconds),
14            None => self
15                .build_time_formatter()
16                .format_crosshair(time, bar_seconds),
17        }
18    }
19
20    /// The chart's clock: the replay time while replaying, else the server time from
21    /// [`set_server_time`](Self::set_server_time) or the feed, when known.
22    pub(super) fn chart_time(&self) -> Option<chrono::DateTime<chrono::Utc>> {
23        self.replay_time().or_else(|| {
24            self.server_time_offset
25                .and_then(|offset| chrono::Utc::now().checked_add_signed(offset))
26                .or_else(|| {
27                    self.feed_link
28                        .as_ref()
29                        .and_then(super::feed::FeedLink::server_time)
30                })
31        })
32    }
33
34    pub(super) fn current_bar_countdown(&self) -> Option<String> {
35        if !self.config.show_countdown || !self.config.show_symbol_last_val {
36            return None;
37        }
38        let last = crate::model::last_with_value(&self.state.data().bars)?;
39        let timeframe = self.timeframe?;
40        if timeframe.is_trade_based() {
41            return None;
42        }
43        let now = self
44            .chart_time()
45            .or_else(|| self.quote.as_ref().map(|quote| quote.ts))?;
46        let session = rendering::exchange_session(
47            self.instrument(),
48            self.config.session_config.as_ref(),
49            &self.display_timezone(),
50            timeframe,
51        );
52        // Calendar bars encode their trading date in the feed's selected zone.
53        let session_open = if self.feed_link.is_some() && timeframe.duration_ms() >= 86_400_000 {
54            let date = match self.display_timezone() {
55                crate::scales::TimezoneMode::Local => {
56                    last.time.with_timezone(&chrono::Local).date_naive()
57                }
58                zone => last
59                    .time
60                    .with_timezone(&zone.to_tz().unwrap_or(crate::model::TimezoneId::utc()))
61                    .date_naive(),
62            };
63            date.and_hms_opt(0, 0, 0)?.and_utc()
64        } else {
65            last.time
66        };
67        let session_close = session.bar_session_close(session_open, timeframe, self.extended_hours);
68        let boundary = self
69            .feed_link
70            .as_ref()
71            .and_then(|link| link.clock.next_open(link.clock.bar_open(last.time)));
72        let close = match (session_close, boundary) {
73            (Some(session), Some(boundary)) if timeframe.duration_ms() < 86_400_000 => {
74                Some(session.min(boundary))
75            }
76            (session, boundary) => session.or(boundary),
77        };
78        let has_session = self
79            .instrument()
80            .and_then(|instrument| instrument.session.as_ref())
81            .is_some_and(|session| {
82                !session.windows.is_empty()
83                    || !session.extended_windows.is_empty()
84                    || !session.overrides.is_empty()
85            })
86            || self.config.session_config.is_some();
87        if self.bar_close_time.is_none() && session_close.is_none() && has_session {
88            return None;
89        }
90        crate::scales::bar_countdown(last.time, timeframe, now, self.bar_close_time.or(close))
91    }
92
93    // Source-bar cadence sets precision even when many elements share one time.
94    pub(super) fn time_label_seconds(&self) -> i64 {
95        if let Some(timeframe) = self
96            .timeframe
97            .filter(|timeframe| !timeframe.is_trade_based())
98        {
99            return timeframe.duration().num_seconds();
100        }
101        let bars = &self.state.data().bars;
102        let shown = self.state.shown();
103        // An empty range starts at 0 before the first bar and at the bar count after the last.
104        let (start, end) = self.state.visible_range();
105        let nearest = |index: usize| shown.get(index.min(shown.len().saturating_sub(1)));
106        let Some((first, last)) = nearest(start).zip(nearest(end.saturating_sub(1).max(start)))
107        else {
108            return 1;
109        };
110        // At least 16 nearby source bars keep a lone session gap from setting the precision.
111        let end = bars
112            .partition_point(|bar| bar.time <= last.time)
113            .max(16)
114            .min(bars.len());
115        let at = bars
116            .partition_point(|bar| bar.time < first.time)
117            .min(end.saturating_sub(16));
118        // Bounded, so a zoomed out view does not sort every visible gap on each frame.
119        let end = end.min(at + 256);
120        if end - at < 2 {
121            return 1;
122        }
123        crate::scales::median_bar_duration(bars[at..end].iter().map(|bar| bar.time)).num_seconds()
124    }
125
126    pub(super) fn display_timezone(&self) -> crate::scales::TimezoneMode {
127        match &self.chart_options.time_scale.timezone {
128            crate::scales::TimezoneMode::Exchange(fallback) => {
129                crate::scales::TimezoneMode::Exchange(
130                    self.instrument()
131                        .and_then(|instrument| instrument.time_zone.as_ref())
132                        .cloned()
133                        .unwrap_or_else(|| fallback.clone()),
134                )
135            }
136            timezone => timezone.clone(),
137        }
138    }
139
140    pub(crate) fn build_time_formatter(&self) -> Box<dyn crate::scales::TimeFormatter> {
141        let mut builder = TimeFormatterBuilder::new()
142            .with_24_hour(true)
143            .with_seconds(self.chart_options.time_scale.seconds_visible)
144            .with_time_visible(self.chart_options.time_scale.time_visible)
145            .with_timezone(self.display_timezone());
146        if let Some(locale) = &self.locale {
147            builder = builder.with_locale(locale.clone());
148        }
149        if let Some(date_format) = &self.date_format {
150            builder = builder.with_date_format(date_format.clone());
151        }
152        if let Some(formatter) = &self.time_formatter {
153            builder = builder.with_formatter(formatter.clone_box());
154        }
155        builder.build()
156    }
157
158    /// Names months and weekdays in time axis, crosshair, tooltip and forecast date labels in
159    /// `locale`'s language, such as `"de-DE"` (see
160    /// [`LocaleTimeFormatter`](crate::scales::LocaleTimeFormatter)).
161    /// `None` restores English names.
162    ///
163    /// The names need the `locales` cargo feature, on by default; without it they stay English.
164    pub fn set_locale(&mut self, locale: Option<String>) {
165        self.locale = locale;
166    }
167
168    /// Writes crosshair, tooltip and forecast dates with a `strftime` pattern, such as
169    /// `"%d.%m.%Y"`, followed by the clock for intraday bars. `None` restores the built-in dates,
170    /// which an invalid pattern also falls back to.
171    pub fn set_date_format(&mut self, date_format: Option<String>) {
172        self.date_format = date_format;
173    }
174
175    /// Formats time axis marks with [`TimeFormatter::format_tick`], whose default calls
176    /// [`TimeFormatter::format`], and crosshair, tooltip, forecast date and
177    /// [`format_time`](Self::format_time) labels with [`TimeFormatter::format_crosshair`].
178    ///
179    /// `format_tick` receives the chart's locale in a
180    /// [`TickMarkContext`](crate::scales::TickMarkContext) and can return `None` to use the
181    /// built-in locale-aware text for that tick. Otherwise the
182    /// formatter replaces the built-in one, the locale and the date format. It receives
183    /// wall-clock times in the chart's time zone, and the time scale's
184    /// [`time_visible`](crate::config::TimeScaleOptions::time_visible) option still applies.
185    /// `None` restores the built-in formatter.
186    ///
187    /// [`TimeFormatter::format`]: crate::scales::TimeFormatter::format
188    /// [`TimeFormatter::format_tick`]: crate::scales::TimeFormatter::format_tick
189    /// [`TimeFormatter::format_crosshair`]: crate::scales::TimeFormatter::format_crosshair
190    pub fn set_time_formatter(
191        &mut self,
192        formatter: Option<std::sync::Arc<dyn crate::scales::TimeFormatter>>,
193    ) {
194        self.time_formatter = formatter;
195    }
196
197    /// Formats price scale labels in the normal and logarithmic modes: the crosshair label and
198    /// price tags with [`PriceFormatter::format`], and the axis ticks with
199    /// [`PriceFormatter::format_tick`]. The formatter receives prices and replaces the
200    /// instrument's decimals. It applies to every price scale and to the prices drawings print;
201    /// studies keep their own formats.
202    /// `None` restores the built-in labels.
203    ///
204    /// [`PriceFormatter::format`]: crate::scales::PriceFormatter::format
205    /// [`PriceFormatter::format_tick`]: crate::scales::PriceFormatter::format_tick
206    pub fn set_price_formatter(
207        &mut self,
208        formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
209    ) {
210        self.price_formatter = formatter;
211    }
212
213    /// Formats price scale labels in the percentage and indexed-to-100 modes, as
214    /// [`set_price_formatter`](Self::set_price_formatter) does in the others. The formatter
215    /// receives the percentage or index value the axis shows. `None` restores signed
216    /// percentages and index values.
217    pub fn set_percentage_formatter(
218        &mut self,
219        formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
220    ) {
221        self.percentage_formatter = formatter;
222    }
223
224    /// Formats volume readouts, including volume-formatted studies. `None` restores the
225    /// built-in suffixes and instrument volume precision. Values stay in volume units.
226    pub fn set_volume_formatter(
227        &mut self,
228        formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
229    ) {
230        self.volume_formatter = formatter.clone();
231        for study in self
232            .studies
233            .indicators_mut()
234            .iter_mut()
235            .chain(self.element_studies.studies.indicators_mut().iter_mut())
236        {
237            if study.value_format() == crate::scales::StudyFormat::Volume {
238                study.set_value_formatter(formatter.clone());
239            }
240        }
241    }
242
243    /// Overrides raw-price formatting for one bar series, including `MAIN`. This affects
244    /// readouts, price tags, ticks and the prices drawings in its pane print; percentage and
245    /// indexed scales retain the chart's percentage formatter. `None` restores chart-wide
246    /// formatting, then the saved [`SeriesStyle::display_format`](crate::model::SeriesStyle::display_format).
247    /// Returns false for an unknown series. A shared axis uses the first visible series'
248    /// formatter. Custom text does not replace the declaration's tick spacing.
249    pub fn set_series_formatter(
250        &mut self,
251        id: super::SeriesId,
252        formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
253    ) -> bool {
254        if !(id == super::SeriesId::MAIN && self.main_series_present
255            || self.price_series.iter().any(|item| item.id == id))
256        {
257            return false;
258        }
259        if let Some(formatter) = formatter {
260            self.series_formatters.insert(id, formatter);
261        } else {
262            self.series_formatters.remove(&id);
263        }
264        true
265    }
266
267    pub(super) fn series_label_formatter(
268        &self,
269        id: super::SeriesId,
270        mode: crate::scales::PriceScaleMode,
271    ) -> Option<std::sync::Arc<dyn crate::scales::PriceFormatter>> {
272        match mode {
273            crate::scales::PriceScaleMode::Percentage
274            | crate::scales::PriceScaleMode::IndexedTo100 => self.percentage_formatter.clone(),
275            _ => self
276                .series_formatters
277                .get(&id)
278                .cloned()
279                .or_else(|| self.price_formatter.clone())
280                .or_else(|| Some(std::sync::Arc::new(self.series_style(id)?.display_format?))),
281        }
282    }
283
284    /// Raw-value formatting: the series' host hook, the chart's, then the saved declaration.
285    pub(super) fn series_price_formatter(
286        &self,
287        id: super::SeriesId,
288    ) -> Option<&dyn crate::scales::PriceFormatter> {
289        self.series_formatters
290            .get(&id)
291            .or(self.price_formatter.as_ref())
292            .map(|formatter| formatter.as_ref())
293            .or_else(|| {
294                self.series_style(id)?
295                    .display_format
296                    .as_ref()
297                    .map(|format| format as &dyn crate::scales::PriceFormatter)
298            })
299    }
300
301    /// First visible bar series owning an axis. The main series precedes added series.
302    pub(super) fn series_axis_owner(&self, id: super::SeriesId) -> super::SeriesId {
303        let (pane, scale) = if id == super::SeriesId::MAIN {
304            // Removing the main series leaves its axis in the price pane.
305            (Some(super::PaneId::MAIN), Some(self.main_series_scale))
306        } else {
307            (self.series_pane(id), self.series_scale(id))
308        };
309        if pane == Some(super::PaneId::MAIN)
310            && scale == Some(self.main_series_scale)
311            && self.main_series_present
312            && self.main_series_visible
313        {
314            return super::SeriesId::MAIN;
315        }
316        self.price_series
317            .iter()
318            .find(|item| {
319                item.visible
320                    && self.series_pane(item.id) == pane
321                    && (pane != Some(super::PaneId::MAIN) || Some(item.options.scale) == scale)
322            })
323            .map_or(id, |item| item.id)
324    }
325
326    pub(super) fn series_display_map(
327        &self,
328        id: super::SeriesId,
329        map: crate::scales::PriceMap,
330    ) -> crate::scales::PriceMap {
331        self.series_style(self.series_axis_owner(id))
332            .and_then(|style| style.display_format)
333            .map_or(map, |format| map.with_display_format(format))
334    }
335
336    pub(crate) fn value_formatters(&self) -> rendering::ValueFormatters<'_> {
337        rendering::ValueFormatters {
338            price: self.series_price_formatter(super::SeriesId::MAIN),
339            volume: self.volume_formatter.as_deref(),
340            percentage: self.percentage_formatter.as_deref(),
341        }
342    }
343
344    /// Overrides automatic session breaks with host logic. `None` restores metadata-based breaks.
345    pub fn set_session_provider(
346        &mut self,
347        provider: Option<std::sync::Arc<dyn crate::model::SessionProvider>>,
348    ) {
349        self.session_provider = provider;
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use crate::chart::series::SeriesSettings;
356    use crate::scales::PriceScaleMode;
357    #[cfg(feature = "timezones")]
358    use crate::widget::test_frames::chart;
359    use crate::widget::test_frames::{frame, shapes};
360    use egui::{Event, Pos2, Shape};
361
362    #[cfg(feature = "timezones")]
363    #[test]
364    fn countdown_uses_feed_calendar_and_session_trading_dates() {
365        use crate::data::{Datafeed, HistoryPage, HistoryRequest, Reply};
366        use crate::model::{
367            Bar, Instrument, Level1Quote, Session, SessionWindow, Timeframe, TimezoneId,
368        };
369        use chrono::{
370            NaiveTime,
371            Weekday::{Fri, Mon, Thu, Tue, Wed},
372        };
373        struct Feed;
374        impl Datafeed for Feed {
375            fn history(&self, _: HistoryRequest, _: Reply<HistoryPage>) {
376                unreachable!("this test only reads the attached calendar");
377            }
378        }
379        for (interval, open, now, session, expected) in [
380            (
381                "7min",
382                "2024-03-31T00:59:00Z",
383                "2024-03-31T00:59:30Z",
384                false,
385                Some("00:30"),
386            ),
387            (
388                "1D",
389                "2024-03-10T23:00:00Z",
390                "2024-03-11T14:59:00Z",
391                true,
392                Some("01:00"),
393            ),
394            (
395                "1W",
396                "2024-03-10T23:00:00Z",
397                "2024-03-15T14:59:00Z",
398                true,
399                Some("01:00"),
400            ),
401            (
402                "1M",
403                "2024-02-29T23:00:00Z",
404                "2024-03-29T14:59:00Z",
405                true,
406                Some("01:00"),
407            ),
408            (
409                "10t",
410                "2024-03-11T14:59:00Z",
411                "2024-03-11T14:59:30Z",
412                false,
413                None,
414            ),
415        ] {
416            let timeframe: Timeframe = interval.parse().unwrap();
417            let mut chart = chart(&[100.0], &[110.0]);
418            chart.timeframe = Some(timeframe);
419            chart.chart_options.time_scale.timezone =
420                crate::scales::TimezoneMode::Exchange("Europe/Zurich".parse().unwrap());
421            chart.attach_feed(std::sync::Arc::new(Feed), 1);
422            let mut instrument =
423                Instrument::new("TEST").with_time_zone(TimezoneId::named("Europe/Zurich").unwrap());
424            if session {
425                instrument.session = Some(Session::new([SessionWindow::new(
426                    [Mon, Tue, Wed, Thu, Fri],
427                    NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
428                    NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
429                )]));
430            }
431            chart.set_instrument(Some(instrument));
432            // Metadata resolves Exchange timezone before the bar is assigned to its slot.
433            chart.feed_link.as_mut().unwrap().clock = chart.bar_clock();
434            chart.set_data(
435                crate::widget::SeriesId::MAIN,
436                vec![Bar::new(
437                    open.parse().unwrap(),
438                    100.0,
439                    110.0,
440                    100.0,
441                    110.0,
442                    1.0,
443                )],
444            );
445            chart.set_quote(Some(Level1Quote {
446                symbol: "TEST".into(),
447                ts: now.parse().unwrap(),
448                bid: 109.0,
449                ask: 111.0,
450                bid_size: 1.0,
451                ask_size: 1.0,
452            }));
453            assert_eq!(
454                chart.current_bar_countdown().as_deref(),
455                expected,
456                "{interval}"
457            );
458        }
459    }
460
461    /// A session gap at the view's edge never changes minute precision. A known daily
462    /// interval keeps date precision even when the clock change makes one bar 23 hours long.
463    #[test]
464    fn time_precision_follows_the_interval_across_gaps_and_clock_changes() {
465        use crate::model::{Bar, BarData, LogicalRange, Timeframe};
466        use chrono::{Duration, TimeZone, Utc};
467        let session = |day, minutes| {
468            let open = Utc.with_ymd_and_hms(2024, 1, day, 14, 30, 0).unwrap();
469            (0..minutes).map(move |minute| {
470                Bar::new(
471                    open + Duration::minutes(minute),
472                    100.0,
473                    110.0,
474                    100.0,
475                    110.0,
476                    1.0,
477                )
478            })
479        };
480        // (Monday bars, left, right, bar formatted, expected)
481        for (monday, left, right, at, expected) in [
482            (390, 390.5, 450.5, 405, "Mon 08 Jan '24 14:45"),
483            (390, 389.5, 450.5, 405, "Mon 08 Jan '24 14:45"),
484            (1, 400.5, 460.5, 390, "Mon 08 Jan '24 14:30"),
485        ] {
486            for timeframe in [
487                Some(Timeframe::Min1),
488                None,
489                Some(Timeframe::Ticks(std::num::NonZeroU64::new(10).unwrap())),
490            ] {
491                let ctx = egui::Context::default();
492                let bars = session(5, 390).chain(session(8, monday)).collect();
493                let mut chart = crate::widget::Chart::new(BarData::from_bars(bars));
494                if let Some(timeframe) = timeframe {
495                    chart.set_timeframe(timeframe);
496                }
497                chart.chart_options.tooltip.enabled = false;
498                frame(&ctx, &mut chart, 0.0, vec![]);
499                assert!(chart.set_visible_logical_range(LogicalRange::new(left, right)));
500                let time = chart.state.data().bars[at].time;
501                assert_eq!(
502                    chart.format_time(time),
503                    expected,
504                    "{left}..{right}, {timeframe:?}"
505                );
506                if at as f32 >= left {
507                    let map = chart.get_chart_mapping();
508                    let painted = shapes(
509                        &ctx,
510                        &mut chart,
511                        0.1,
512                        vec![Event::PointerMoved(Pos2::new(
513                            map.idx_to_x(at),
514                            map.rect.center().y,
515                        ))],
516                    );
517                    assert!(
518                        painted.iter().any(|shape| matches!(shape,
519                            Shape::Text(text) if text.galley.text() == expected
520                        )),
521                        "crosshair: {left}..{right}, {timeframe:?}"
522                    );
523                }
524            }
525        }
526        #[cfg(feature = "timezones")]
527        for (left, right) in [(39.5, 69.5), (38.5, 68.5)] {
528            let ctx = egui::Context::default();
529            let first = Utc.with_ymd_and_hms(2024, 2, 1, 5, 0, 0).unwrap();
530            // New York midnight is 05:00Z through March 10, then 04:00Z from March 11.
531            let bars = (0..90)
532                .map(|day| {
533                    let time = first + Duration::days(day)
534                        - if day >= 39 {
535                            Duration::hours(1)
536                        } else {
537                            Duration::zero()
538                        };
539                    Bar::new(time, 100.0, 110.0, 100.0, 110.0, 1.0)
540                })
541                .collect();
542            let mut chart = crate::widget::Chart::new(BarData::from_bars(bars));
543            chart.set_timeframe(Timeframe::Day1);
544            chart.chart_options.time_scale.timezone =
545                crate::scales::TimezoneMode::Timezone("America/New_York".parse().unwrap());
546            frame(&ctx, &mut chart, 0.0, vec![]);
547            assert!(chart.set_visible_logical_range(LogicalRange::new(left, right)));
548            assert_eq!(
549                chart.format_time(chart.state.data().bars[39].time),
550                "Mar 11, 2024",
551                "{left}..{right}"
552            );
553        }
554    }
555
556    #[test]
557    fn tick_context_tracks_locale_and_falls_back_without_changing_crosshair() {
558        use crate::model::{BarData, Timeframe};
559        use crate::scales::{TickMarkContext, TickMarkType, TimeFormatter, TimezoneMode};
560        use chrono::{DateTime, Utc};
561        use std::sync::Arc;
562
563        #[derive(Clone)]
564        struct Host;
565        impl TimeFormatter for Host {
566            fn format(&self, _: DateTime<Utc>, _: TickMarkType) -> String {
567                "legacy tick".into()
568            }
569            fn format_tick(&self, context: TickMarkContext<'_>) -> Option<String> {
570                match context.mark_type {
571                    TickMarkType::Year => Some(format!(
572                        "{} {}",
573                        context.locale.unwrap_or("default"),
574                        context.time.format("%Y-%m-%d %H:%M:%S%.3f")
575                    )),
576                    TickMarkType::TimeWithMilliseconds => Some(String::new()),
577                    _ => None,
578                }
579            }
580            fn format_crosshair(&self, time: DateTime<Utc>, _: i64) -> String {
581                format!("hover {}", time.format("%H:%M"))
582            }
583            fn clone_box(&self) -> Box<dyn TimeFormatter> {
584                Box::new(self.clone())
585            }
586        }
587
588        let time = "2024-02-29T19:00:01.123Z".parse().unwrap();
589        let mut chart = crate::widget::Chart::new(BarData::from_bars(vec![]));
590        chart.set_timeframe(Timeframe::Min1);
591        chart.chart_options.time_scale.timezone = TimezoneMode::Timezone("+05:45".parse().unwrap());
592        chart.set_time_formatter(Some(Arc::new(Host)));
593        chart.set_date_format(Some("%d.%m.%Y".into()));
594        // Add 5h45m by hand: leap day 19:00 UTC becomes March 1, 00:45 display time.
595        for (locale, prefix, month) in [
596            (Some("en-US"), "en-US", "Mar"),
597            (
598                Some("de-DE"),
599                "de-DE",
600                if cfg!(feature = "locales") {
601                    "Mär"
602                } else {
603                    "Mar"
604                },
605            ),
606            (Some("unknown"), "unknown", "Mar"),
607            (None, "default", "Mar"),
608        ] {
609            chart.set_locale(locale.map(str::to_owned));
610            let formatter = chart.build_time_formatter();
611            for formatter in [formatter.clone_box(), formatter] {
612                for (kind, expected) in [
613                    (
614                        TickMarkType::Year,
615                        format!("{prefix} 2024-03-01 00:45:01.123"),
616                    ),
617                    (TickMarkType::Month, month.into()),
618                    (TickMarkType::DayOfMonth, "01".into()),
619                    (TickMarkType::Time, "00:45".into()),
620                    (TickMarkType::TimeWithSeconds, "00:45:01".into()),
621                    (TickMarkType::TimeWithMilliseconds, String::new()),
622                ] {
623                    assert_eq!(
624                        formatter.format(time, kind),
625                        expected,
626                        "{locale:?}, {kind:?}"
627                    );
628                }
629                assert_eq!(formatter.format_crosshair(time, 60), "hover 00:45");
630            }
631            assert_eq!(chart.format_time(time), "hover 00:45");
632        }
633        chart.chart_options.time_scale.seconds_visible = false;
634        assert_eq!(
635            chart
636                .build_time_formatter()
637                .format(time, TickMarkType::TimeWithSeconds),
638            "00:45"
639        );
640        chart.chart_options.time_scale.time_visible = false;
641        assert_eq!(
642            chart
643                .build_time_formatter()
644                .format(time, TickMarkType::Time),
645            "hover 00:45"
646        );
647    }
648
649    #[test]
650    fn host_formatters_reach_axes_crosshair_and_tags() {
651        use crate::model::{Bar, BarData, Timeframe};
652        use crate::scales::{PriceFormatter, TickMarkType, TimeFormatter};
653        use chrono::{DateTime, Duration, TimeZone, Utc};
654        use std::sync::Arc;
655        #[derive(Clone)]
656        struct Host;
657        impl TimeFormatter for Host {
658            fn format(&self, _: DateTime<Utc>, _: TickMarkType) -> String {
659                "tick time".into()
660            }
661            fn format_crosshair(&self, _: DateTime<Utc>, _: i64) -> String {
662                "hover time".into()
663            }
664            fn clone_box(&self) -> Box<dyn TimeFormatter> {
665                Box::new(Host)
666            }
667        }
668        impl PriceFormatter for Host {
669            fn format(&self, _: f64) -> String {
670                "label price".into()
671            }
672            fn format_tick(&self, _: f64) -> String {
673                "tick price".into()
674            }
675            fn clone_box(&self) -> Box<dyn PriceFormatter> {
676                Box::new(Host)
677            }
678        }
679        // Wednesday 13 March 2024, 14:30 UTC, at the hovered bar.
680        let time = Utc.with_ymd_and_hms(2024, 3, 13, 14, 30, 0).unwrap();
681        type Setup = fn(&mut crate::widget::Chart);
682        let cases: [(Setup, &[&str]); 6] = [
683            (
684                |chart| chart.set_locale(Some("de-DE".into())),
685                &[if cfg!(feature = "locales") {
686                    "Mi 13 Mär '24 14:30"
687                } else {
688                    "Wed 13 Mar '24 14:30"
689                }],
690            ),
691            (
692                |chart| chart.set_date_format(Some("%d.%m.%Y".into())),
693                &["13.03.2024 14:30"],
694            ),
695            (
696                |chart| chart.set_time_formatter(Some(Arc::new(Host))),
697                &["hover time", "tick time"],
698            ),
699            (
700                |chart| chart.set_price_formatter(Some(Arc::new(Host))),
701                &["label price", "tick price", "O label price"],
702            ),
703            (
704                |chart| {
705                    chart.add_indicator(Box::new(
706                        crate::studies::CustomIndicator::new("Volume units", |bars| {
707                            vec![crate::studies::IndicatorValue::Single(99.0); bars.len()]
708                        })
709                        .with_value_format(crate::scales::StudyFormat::Volume),
710                    ));
711                    chart.set_volume_formatter(Some(Arc::new(Host)));
712                },
713                &["label price"],
714            ),
715            (
716                |chart| {
717                    chart.set_percentage_formatter(Some(Arc::new(Host)));
718                    chart.apply_series_settings(&SeriesSettings {
719                        price_scale_mode: PriceScaleMode::Percentage,
720                        ..SeriesSettings::default()
721                    });
722                },
723                &["label price", "tick price"],
724            ),
725        ];
726        for (setup, expected) in cases {
727            let ctx = egui::Context::default();
728            let bars = (0..100)
729                .map(|i| {
730                    let open = 100.0 + i as f64;
731                    Bar::new(
732                        time + Duration::minutes(i - 50),
733                        open,
734                        open + 2.0,
735                        open - 1.0,
736                        open + 1.0,
737                        1.0,
738                    )
739                })
740                .collect();
741            let mut chart = crate::widget::Chart::new(BarData::from_bars(bars));
742            chart.set_timeframe(Timeframe::Min1);
743            chart.chart_options.tooltip.enabled = false;
744            frame(&ctx, &mut chart, 0.0, vec![]);
745            // After a first frame, so the formatters reach a chart that has already drawn.
746            setup(&mut chart);
747            frame(&ctx, &mut chart, 0.05, vec![]);
748            let map = chart.get_chart_mapping();
749            let pos = Pos2::new(map.idx_to_x(50), map.rect.center().y);
750            let painted = shapes(&ctx, &mut chart, 0.1, vec![Event::PointerMoved(pos)]);
751            for text in expected {
752                assert!(
753                    painted.iter().any(
754                        |shape| matches!(shape, Shape::Text(shape) if shape.galley.text() == *text)
755                    ),
756                    "{text}"
757                );
758            }
759            if chart.volume_formatter.is_some() {
760                // The fallback volume row combines its prefix and value in one text shape.
761                let labels = painted
762                    .iter()
763                    .filter(|shape| {
764                        matches!(shape,
765                            Shape::Text(text) if text.pos.x < chart.last_rendered_price_rect.center().x
766                                && text.galley.job.sections.iter().any(|section| {
767                                    &text.galley.job.text[section.byte_range.start.0..section.byte_range.end.0]
768                                        == "label price"
769                                })
770                        )
771                    })
772                    .count();
773                assert_eq!(
774                    labels, 2,
775                    "the volume row and overlay legend use the host formatter"
776                );
777            }
778            if expected.contains(&"O label price") {
779                chart.chart_options.tooltip.enabled = true;
780                chart.chart_options.tooltip.on_long_press = false;
781                let painted = shapes(&ctx, &mut chart, 0.2, vec![Event::PointerMoved(pos)]);
782                let labels = painted.iter().filter(|shape| matches!(shape, Shape::Text(text) if text.galley.text() == "label price")).count();
783                assert!(
784                    labels >= 5,
785                    "OHLC tooltip values must use the host formatter: {labels}"
786                );
787            }
788        }
789    }
790
791    #[cfg(feature = "timezones")]
792    #[test]
793    fn hover_times_share_the_chart_zone_through_dst() {
794        use crate::model::{Bar, BarData, Timeframe};
795        use crate::scales::TimezoneMode;
796        use chrono::{Duration, TimeZone, Utc};
797        // US: UTC-5 to -4 at 07:00Z in March, -4 to -5 at 06:00Z in November.
798        // Zurich: UTC+1 to +2 at 01:00Z in March, +2 to +1 at 01:00Z in October.
799        for (zone, month, day, hour, before, after) in [
800            (
801                "America/New_York",
802                3,
803                10,
804                7,
805                "Sun 10 Mar '24 01:59:59",
806                "Sun 10 Mar '24 03:00:00",
807            ),
808            (
809                "America/New_York",
810                11,
811                3,
812                6,
813                "Sun 03 Nov '24 01:59:59",
814                "Sun 03 Nov '24 01:00:00",
815            ),
816            (
817                "Europe/Zurich",
818                3,
819                31,
820                1,
821                "Sun 31 Mar '24 01:59:59",
822                "Sun 31 Mar '24 03:00:00",
823            ),
824            (
825                "Europe/Zurich",
826                10,
827                27,
828                1,
829                "Sun 27 Oct '24 02:59:59",
830                "Sun 27 Oct '24 02:00:00",
831            ),
832        ] {
833            let transition = Utc.with_ymd_and_hms(2024, month, day, hour, 0, 0).unwrap();
834            for (time, expected) in [
835                (transition - Duration::seconds(1), before),
836                (transition, after),
837            ] {
838                for mode in [
839                    crate::config::TooltipMode::Floating,
840                    crate::config::TooltipMode::Tracking,
841                ] {
842                    let ctx = egui::Context::default();
843                    let bars = (0..100)
844                        .map(|i| {
845                            Bar::new(
846                                time + Duration::seconds(i - 50),
847                                100.0,
848                                102.0,
849                                99.0,
850                                101.0,
851                                1.0,
852                            )
853                        })
854                        .collect();
855                    let mut chart = crate::widget::Chart::new(BarData::from_bars(bars));
856                    let study = chart.add_indicator(Box::new(
857                        crate::studies::CustomIndicator::new("Fixture", |bars| {
858                            vec![crate::studies::IndicatorValue::Single(0.12345); bars.len()]
859                        }),
860                    ));
861                    let study = chart.indicator_mut(study).unwrap();
862                    let mut settings = study.settings();
863                    settings.precision = Some(3);
864                    study.apply_settings(&[], settings).unwrap();
865                    chart.set_timeframe(Timeframe::Sec1);
866                    chart.chart_options.time_scale.timezone = TimezoneMode::nyse();
867                    chart.set_instrument(Some(
868                        crate::model::Instrument::new("DST")
869                            .with_time_zone(crate::model::TimezoneId::named(zone).unwrap()),
870                    ));
871                    assert_eq!(chart.format_time(time), expected);
872                    chart.chart_options.tooltip.mode = mode;
873                    chart.chart_options.tooltip.show_time = true;
874                    chart.chart_options.tooltip.on_long_press = false;
875                    frame(&ctx, &mut chart, 0.0, vec![]);
876                    let map = chart.get_chart_mapping();
877                    let pos = Pos2::new(map.idx_to_x(50), map.rect.center().y);
878                    let painted = shapes(&ctx, &mut chart, 0.1, vec![Event::PointerMoved(pos)]);
879                    let times: Vec<_> = painted
880                        .iter()
881                        .filter_map(|shape| match shape {
882                            Shape::Text(text) if text.galley.text().contains(expected) => {
883                                Some(text.galley.text())
884                            }
885                            _ => None,
886                        })
887                        .collect();
888                    let values: Vec<_> = painted
889                        .iter()
890                        .filter_map(|shape| match shape {
891                            Shape::Text(text) => Some(text.galley.text()),
892                            _ => None,
893                        })
894                        .collect();
895                    match mode {
896                        crate::config::TooltipMode::Floating => {
897                            assert_eq!(
898                                values.iter().filter(|text| **text == "Fixture").count(),
899                                2,
900                                "{values:?}"
901                            );
902                            assert_eq!(
903                                values
904                                    .windows(2)
905                                    .filter(|pair| *pair == ["Fixture", "0.123"])
906                                    .count(),
907                                2,
908                                "{values:?}"
909                            );
910                        }
911                        _ => assert!(
912                            values.iter().any(|text| text.contains("Fixture: 0.123")),
913                            "{values:?}"
914                        ),
915                    }
916                    assert_eq!(
917                        times.len(),
918                        2,
919                        "{zone} {time} {mode:?}: expected matching tooltip and crosshair, {times:?}"
920                    );
921                }
922            }
923        }
924    }
925}