Skip to main content

egui_charts/scales/
time_formatter.rs

1//! Time formatting for X-axis labels.
2//!
3//! The [`TimeFormatter`] trait controls how timestamps are rendered as labels
4//! on the time axis.  The library ships with [`DefaultTimeFormatter`],
5//! [`LocaleTimeFormatter`], [`RelativeTimeFormatter`], and a
6//! [`TimezoneAwareFormatter`] wrapper for automatic timezone conversion.
7//!
8//! Use [`TimeFormatterBuilder`] for a fluent construction API.
9
10use super::timescale_marks::TickMarkType;
11use crate::model::TimezoneId;
12use chrono::{DateTime, Utc};
13use std::sync::Arc;
14
15#[cfg(test)]
16use chrono::Datelike;
17
18/// Display time and chart locale for one time-axis tick.
19#[derive(Debug, Clone, Copy)]
20pub struct TickMarkContext<'a> {
21    /// Wall-clock fields in the chart's display zone, carried in a UTC value.
22    /// This is not a UTC instant and must not be converted to the display zone again.
23    pub time: DateTime<Utc>,
24    /// Calendar or clock precision selected for this tick.
25    pub mark_type: TickMarkType,
26    /// Active chart locale, such as `"de-DE"`; `None` uses English names.
27    /// Unknown identifiers and builds without `locales` also use English names.
28    pub locale: Option<&'a str>,
29}
30
31/// Trait for custom time formatting
32/// Allows users to customize how time labels are displayed on the axis
33pub trait TimeFormatter: Send + Sync {
34    /// Format a ts into a display string
35    ///
36    /// # Arguments
37    /// * `time` - The ts to format
38    /// * `mark_type` - The type of tick mark (Year, Month, etc.)
39    ///
40    /// # Returns
41    /// Formatted string to display on the time axis
42    fn format(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String;
43
44    /// Formats one axis tick with the active chart locale and display-time fields.
45    ///
46    /// Return `None` to use the chart's normal locale and clock settings for this tick.
47    /// `Some(String::new())` keeps an empty label. The default calls [`format`](Self::format),
48    /// preserving existing formatters. Builders and the chart call this after time-zone
49    /// conversion; hidden intraday clocks still use date labels instead.
50    /// This hook does not change [`format_crosshair`](Self::format_crosshair).
51    fn format_tick(&self, context: TickMarkContext<'_>) -> Option<String> {
52        Some(self.format(context.time, context.mark_type))
53    }
54
55    /// Clock-face time used to identify calendar boundaries. The returned UTC value
56    /// holds local fields, not another instant.
57    fn wall_time(&self, time: DateTime<Utc>) -> DateTime<Utc> {
58        time
59    }
60
61    /// Converts local calendar fields from `wall_time` back to an instant.
62    /// Repeated times use the first instant; skipped times use the clock jump.
63    fn utc_time(&self, wall_time: DateTime<Utc>) -> Option<DateTime<Utc>> {
64        Some(wall_time)
65    }
66
67    /// Whether intraday marks and hover labels include the clock.
68    fn time_visible(&self) -> bool {
69        true
70    }
71
72    /// Formats the hovered bar, retaining this formatter's date and clock conventions.
73    fn format_crosshair(&self, time: DateTime<Utc>, bar_seconds: i64) -> String {
74        let mut label = self.format(time, TickMarkType::DayOfMonth);
75        let year = self.format(time, TickMarkType::Year);
76        if year != self.format(Utc::now(), TickMarkType::Year) {
77            label = format!("{label}, {year}");
78        }
79        match bar_seconds {
80            86_400.. => label,
81            60.. => format!("{label} {}", self.format(time, TickMarkType::Time)),
82            1.. => format!(
83                "{label} {}",
84                self.format(time, TickMarkType::TimeWithSeconds)
85            ),
86            _ => format!(
87                "{label} {}",
88                self.format(time, TickMarkType::TimeWithMilliseconds)
89            ),
90        }
91    }
92
93    /// Clone this formatter into a Box
94    fn clone_box(&self) -> Box<dyn TimeFormatter>;
95}
96
97/// Default time formatter using standard formats
98#[derive(Debug, Clone)]
99pub struct DefaultTimeFormatter {
100    /// Whether to use 24-hour format
101    pub use_24_hour: bool,
102
103    /// Whether to show seconds
104    pub show_seconds: bool,
105
106    /// `strftime` pattern for the date in crosshair and tooltip labels, such as
107    /// `"%d.%m.%Y"`, followed by the clock for intraday bars. `None` uses the
108    /// built-in dates; an invalid pattern falls back to them.
109    pub date_format: Option<String>,
110}
111
112impl Default for DefaultTimeFormatter {
113    fn default() -> Self {
114        Self {
115            use_24_hour: true,
116            show_seconds: true,
117            date_format: None,
118        }
119    }
120}
121
122impl DefaultTimeFormatter {
123    fn style(&self) -> Style<'_> {
124        Style {
125            use_24_hour: self.use_24_hour,
126            show_seconds: self.show_seconds,
127            date_format: self.date_format.as_deref(),
128            #[cfg(feature = "locales")]
129            locale: None,
130        }
131    }
132
133    fn into_box(self) -> Box<dyn TimeFormatter> {
134        if self.date_format.is_none() {
135            Box::new(ClockTimeFormatter {
136                use_24_hour: self.use_24_hour,
137                show_seconds: self.show_seconds,
138            })
139        } else {
140            Box::new(self)
141        }
142    }
143}
144
145impl TimeFormatter for DefaultTimeFormatter {
146    fn format(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
147        self.style().format(time, mark_type)
148    }
149
150    fn format_crosshair(&self, time: DateTime<Utc>, bar_seconds: i64) -> String {
151        self.style().format_crosshair(time, bar_seconds)
152    }
153
154    fn clone_box(&self) -> Box<dyn TimeFormatter> {
155        self.clone().into_box()
156    }
157}
158
159/// Default clock options without storage for an unused custom date pattern.
160#[derive(Clone)]
161struct ClockTimeFormatter {
162    use_24_hour: bool,
163    show_seconds: bool,
164}
165
166impl TimeFormatter for ClockTimeFormatter {
167    fn format(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
168        DefaultTimeFormatter {
169            use_24_hour: self.use_24_hour,
170            show_seconds: self.show_seconds,
171            date_format: None,
172        }
173        .format(time, mark_type)
174    }
175
176    fn format_crosshair(&self, time: DateTime<Utc>, bar_seconds: i64) -> String {
177        DefaultTimeFormatter {
178            use_24_hour: self.use_24_hour,
179            show_seconds: self.show_seconds,
180            date_format: None,
181        }
182        .format_crosshair(time, bar_seconds)
183    }
184
185    fn clone_box(&self) -> Box<dyn TimeFormatter> {
186        Box::new(self.clone())
187    }
188}
189
190/// Label layout shared by the default and locale formatters.
191struct Style<'a> {
192    use_24_hour: bool,
193    show_seconds: bool,
194    date_format: Option<&'a str>,
195    /// Language of the names; `None` writes English ones.
196    #[cfg(feature = "locales")]
197    locale: Option<chrono::Locale>,
198}
199
200impl Style<'_> {
201    /// Writes `time` with a `strftime` pattern, or `None` when the pattern is invalid.
202    fn write(&self, time: DateTime<Utc>, pattern: &str) -> Option<String> {
203        use std::fmt::Write;
204        let mut text = String::new();
205        #[cfg(feature = "locales")]
206        if let Some(locale) = self.locale {
207            write!(text, "{}", time.format_localized(pattern, locale)).ok()?;
208            // Some locales pad their names, as in Japanese " 5月".
209            return Some(text.split_whitespace().collect::<Vec<_>>().join(" "));
210        }
211        write!(text, "{}", time.format(pattern)).ok()?;
212        Some(text)
213    }
214
215    fn format(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
216        let pattern = match (mark_type, self.use_24_hour) {
217            (TickMarkType::Year, _) => "%Y",
218            (TickMarkType::Month, _) => "%b",
219            (TickMarkType::DayOfMonth, _) => "%d",
220            (TickMarkType::TimeWithMilliseconds, true) if self.show_seconds => "%H:%M:%S%.3f",
221            (TickMarkType::TimeWithMilliseconds, false) if self.show_seconds => "%I:%M:%S%.3f %p",
222            (TickMarkType::TimeWithSeconds, true) if self.show_seconds => "%H:%M:%S",
223            (TickMarkType::TimeWithSeconds, false) if self.show_seconds => "%I:%M:%S %p",
224            // Without seconds, second marks show the minute clock.
225            (_, true) => "%H:%M",
226            (_, false) => "%I:%M %p",
227        };
228        self.write(time, pattern).unwrap_or_default()
229    }
230
231    fn format_crosshair(&self, time: DateTime<Utc>, bar_seconds: i64) -> String {
232        let date = self
233            .date_format
234            .and_then(|pattern| self.write(time, pattern));
235        let custom_date = date.is_some();
236        let daily = bar_seconds >= 86_400;
237        let pattern = if daily { "%b %d" } else { "%a %d %b '%y" };
238        #[cfg(feature = "locales")]
239        let date = date.or_else(|| self.locale.and_then(|_| self.write(time, pattern)));
240        // The built-in English date writes straight into the label without a temporary String.
241        let default_date = time.format(pattern);
242        let date: &dyn std::fmt::Display = match &date {
243            Some(date) => date,
244            None => &default_date,
245        };
246        if daily {
247            return if custom_date
248                || time.format("%Y").to_string() == Utc::now().format("%Y").to_string()
249            {
250                date.to_string()
251            } else {
252                format!("{date}, {}", time.format("%Y"))
253            };
254        }
255        let kind = if bar_seconds >= 60 {
256            TickMarkType::Time
257        } else if bar_seconds >= 1 {
258            TickMarkType::TimeWithSeconds
259        } else {
260            TickMarkType::TimeWithMilliseconds
261        };
262        let clock = self.format(time, kind);
263        format!("{date} {clock}")
264    }
265}
266
267/// Locale-aware time formatter: month and weekday names and the AM/PM marker
268/// in the locale's language, laid out as [`DefaultTimeFormatter`] does.
269///
270/// The names need the `locales` cargo feature, on by default; without it they
271/// are English, as with an unknown locale.
272#[derive(Debug, Clone)]
273pub struct LocaleTimeFormatter {
274    /// Locale identifier: language and region, such as `"en-US"`, `"ja-JP"` or
275    /// `"de-DE"` (`"de_DE"` also works). Unknown identifiers use English names.
276    pub locale: String,
277
278    /// Whether to use 24-hour format
279    pub use_24_hour: bool,
280
281    /// Whether to show seconds
282    pub show_seconds: bool,
283
284    /// `strftime` pattern for the date in crosshair and tooltip labels; see
285    /// [`DefaultTimeFormatter::date_format`].
286    pub date_format: Option<String>,
287}
288
289impl LocaleTimeFormatter {
290    /// Create a new locale-aware formatter
291    pub fn new(locale: impl Into<String>) -> Self {
292        let locale_str = locale.into();
293        let use_24_hour = Self::default_24hour_for_locale(&locale_str);
294        Self {
295            locale: locale_str,
296            use_24_hour,
297            show_seconds: true,
298            date_format: None,
299        }
300    }
301
302    /// Determine default 24-hour preference for locale
303    fn default_24hour_for_locale(locale: &str) -> bool {
304        !matches!(locale.replace('_', "-").as_str(), "en-US" | "en-CA")
305    }
306
307    fn style(&self) -> Style<'_> {
308        Style {
309            use_24_hour: self.use_24_hour,
310            show_seconds: self.show_seconds,
311            date_format: self.date_format.as_deref(),
312            #[cfg(feature = "locales")]
313            locale: Some(
314                chrono::Locale::try_from(self.locale.replace('-', "_").as_str())
315                    .unwrap_or(chrono::Locale::POSIX),
316            ),
317        }
318    }
319}
320
321impl TimeFormatter for LocaleTimeFormatter {
322    fn format(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
323        self.style().format(time, mark_type)
324    }
325
326    fn format_crosshair(&self, time: DateTime<Utc>, bar_seconds: i64) -> String {
327        self.style().format_crosshair(time, bar_seconds)
328    }
329
330    fn clone_box(&self) -> Box<dyn TimeFormatter> {
331        Box::new(self.clone())
332    }
333}
334
335/// Custom time formatter using a closure
336#[derive(Clone)]
337pub struct CustomTimeFormatter {
338    formatter: Arc<dyn Fn(DateTime<Utc>, TickMarkType) -> String + Send + Sync>,
339}
340
341impl CustomTimeFormatter {
342    /// Create a new custom formatter from a closure
343    pub fn new<F>(formatter: F) -> Self
344    where
345        F: Fn(DateTime<Utc>, TickMarkType) -> String + Send + Sync + 'static,
346    {
347        Self {
348            formatter: Arc::new(formatter),
349        }
350    }
351}
352
353impl TimeFormatter for CustomTimeFormatter {
354    fn format(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
355        (self.formatter)(time, mark_type)
356    }
357
358    fn clone_box(&self) -> Box<dyn TimeFormatter> {
359        Box::new(self.clone())
360    }
361}
362
363/// Keeps per-tick fallback separate from the host's crosshair formatting.
364struct TickContextFormatter {
365    formatter: Box<dyn TimeFormatter>,
366    fallback: Box<dyn TimeFormatter>,
367    locale: Option<String>,
368}
369
370impl TimeFormatter for TickContextFormatter {
371    fn format(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
372        self.formatter
373            .format_tick(TickMarkContext {
374                time,
375                mark_type,
376                locale: self.locale.as_deref(),
377            })
378            .unwrap_or_else(|| self.fallback.format(time, mark_type))
379    }
380
381    fn format_crosshair(&self, time: DateTime<Utc>, bar_seconds: i64) -> String {
382        self.formatter.format_crosshair(time, bar_seconds)
383    }
384
385    fn clone_box(&self) -> Box<dyn TimeFormatter> {
386        Box::new(Self {
387            formatter: self.formatter.clone_box(),
388            fallback: self.fallback.clone_box(),
389            locale: self.locale.clone(),
390        })
391    }
392}
393
394/// Relative time formatter (e.g., "2h ago", "yesterday")
395#[derive(Debug, Clone)]
396pub struct RelativeTimeFormatter {
397    /// Reference time for relative calculations (usually "now")
398    pub reference_time: DateTime<Utc>,
399}
400
401impl RelativeTimeFormatter {
402    /// Create a new relative time formatter
403    pub fn new(reference_time: DateTime<Utc>) -> Self {
404        Self { reference_time }
405    }
406
407    /// Create a formatter relative to current time
408    pub fn now() -> Self {
409        Self {
410            reference_time: Utc::now(),
411        }
412    }
413}
414
415impl TimeFormatter for RelativeTimeFormatter {
416    fn format(&self, time: DateTime<Utc>, _mark_type: TickMarkType) -> String {
417        let diff = self.reference_time.signed_duration_since(time);
418
419        if diff.num_seconds() < 0 {
420            return "future".to_string();
421        }
422
423        let seconds = diff.num_seconds();
424
425        if seconds < 60 {
426            format!("{seconds}s ago")
427        } else if seconds < 3600 {
428            format!("{}m ago", seconds / 60)
429        } else if seconds < 86400 {
430            format!("{}h ago", seconds / 3600)
431        } else if seconds < 604800 {
432            format!("{}d ago", seconds / 86400)
433        } else if seconds < 2592000 {
434            format!("{}w ago", seconds / 604800)
435        } else if seconds < 31536000 {
436            format!("{}mo ago", seconds / 2592000)
437        } else {
438            format!("{}y ago", seconds / 31536000)
439        }
440    }
441
442    fn clone_box(&self) -> Box<dyn TimeFormatter> {
443        Box::new(self.clone())
444    }
445}
446
447/// Timezone display mode for time axis labels.
448#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
449pub enum TimezoneMode {
450    /// Display in UTC
451    #[default]
452    Utc,
453    /// Display in user's local timezone
454    Local,
455    /// Display in specific timezone
456    Timezone(TimezoneId),
457    /// Display in exchange/venue timezone (user must specify via Timezone)
458    Exchange(TimezoneId),
459}
460
461impl TimezoneMode {
462    /// New York exchange time, with daylight-saving rules (`timezones` feature).
463    #[cfg(feature = "timezones")]
464    pub fn nyse() -> Self {
465        TimezoneMode::Exchange("America/New_York".parse().unwrap())
466    }
467
468    /// London exchange time, with daylight-saving rules (`timezones` feature).
469    #[cfg(feature = "timezones")]
470    pub fn lse() -> Self {
471        TimezoneMode::Exchange("Europe/London".parse().unwrap())
472    }
473
474    /// Tokyo exchange time (`timezones` feature).
475    #[cfg(feature = "timezones")]
476    pub fn jse() -> Self {
477        TimezoneMode::Exchange("Asia/Tokyo".parse().unwrap())
478    }
479
480    /// Hong Kong exchange time (`timezones` feature).
481    #[cfg(feature = "timezones")]
482    pub fn hkex() -> Self {
483        TimezoneMode::Exchange("Asia/Hong_Kong".parse().unwrap())
484    }
485
486    /// Get the selected zone for conversion; UTC and system local time return `None`.
487    pub fn to_tz(&self) -> Option<TimezoneId> {
488        match self {
489            TimezoneMode::Utc => None,   // Already UTC
490            TimezoneMode::Local => None, // Will use system local
491            TimezoneMode::Timezone(tz) | TimezoneMode::Exchange(tz) => Some(tz.clone()),
492        }
493    }
494}
495
496/// Timezone-aware formatter wrapper
497/// Converts ts to target timezone before formatting
498pub(crate) struct TimezoneAwareFormatter<T> {
499    inner: Box<dyn TimeFormatter>,
500    timezone: T,
501    time_visible: bool,
502}
503
504impl<T: Clone> Clone for TimezoneAwareFormatter<T> {
505    fn clone(&self) -> Self {
506        Self {
507            inner: self.inner.clone_box(),
508            timezone: self.timezone.clone(),
509            time_visible: self.time_visible,
510        }
511    }
512}
513
514impl<T: chrono::TimeZone> TimezoneAwareFormatter<T> {
515    /// Create a new timezone-aware formatter
516    pub(crate) fn new(inner: Box<dyn TimeFormatter>, timezone: T, time_visible: bool) -> Self {
517        Self {
518            inner,
519            timezone,
520            time_visible,
521        }
522    }
523
524    /// Convert UTC ts to target timezone
525    fn convert_timezone(&self, utc_time: DateTime<Utc>) -> DateTime<Utc> {
526        use crate::model::local_time;
527        local_time(self.timezone.clone(), utc_time).and_utc()
528    }
529}
530
531impl<T: chrono::TimeZone + Send + Sync + 'static> TimeFormatter for TimezoneAwareFormatter<T> {
532    fn format(&self, time: DateTime<Utc>, mark_type: TickMarkType) -> String {
533        let converted = self.convert_timezone(time);
534        if !self.time_visible
535            && matches!(
536                mark_type,
537                TickMarkType::Time
538                    | TickMarkType::TimeWithSeconds
539                    | TickMarkType::TimeWithMilliseconds
540            )
541        {
542            self.inner.format_crosshair(converted, 86_400)
543        } else {
544            self.inner.format(converted, mark_type)
545        }
546    }
547
548    fn time_visible(&self) -> bool {
549        self.time_visible
550    }
551
552    fn wall_time(&self, time: DateTime<Utc>) -> DateTime<Utc> {
553        self.convert_timezone(time)
554    }
555
556    fn utc_time(&self, time: DateTime<Utc>) -> Option<DateTime<Utc>> {
557        use crate::model::local_instant;
558        local_instant(self.timezone.clone(), time.naive_utc())
559    }
560
561    fn format_crosshair(&self, time: DateTime<Utc>, bar_seconds: i64) -> String {
562        self.inner.format_crosshair(
563            self.convert_timezone(time),
564            if self.time_visible {
565                bar_seconds
566            } else {
567                86_400
568            },
569        )
570    }
571
572    fn clone_box(&self) -> Box<dyn TimeFormatter> {
573        Box::new(self.clone())
574    }
575}
576
577/// Builder for creating time formatters
578pub struct TimeFormatterBuilder {
579    use_24_hour: bool,
580    show_seconds: bool,
581    locale: Option<String>,
582    date_format: Option<String>,
583    formatter: Option<Box<dyn TimeFormatter>>,
584    timezone: Option<TimezoneMode>,
585    time_visible: bool,
586}
587
588impl TimeFormatterBuilder {
589    /// Create a new formatter builder
590    pub fn new() -> Self {
591        Self {
592            use_24_hour: true,
593            show_seconds: true,
594            locale: None,
595            date_format: None,
596            formatter: None,
597            timezone: None,
598            time_visible: true,
599        }
600    }
601
602    /// Set 24-hour format
603    pub fn with_24_hour(mut self, use_24_hour: bool) -> Self {
604        self.use_24_hour = use_24_hour;
605        self
606    }
607
608    /// Set seconds display
609    pub fn with_seconds(mut self, show_seconds: bool) -> Self {
610        self.show_seconds = show_seconds;
611        self
612    }
613
614    /// Show the clock in intraday marks and hover labels, or dates alone.
615    pub fn with_time_visible(mut self, time_visible: bool) -> Self {
616        self.time_visible = time_visible;
617        self
618    }
619
620    /// Set locale
621    pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
622        self.locale = Some(locale.into());
623        self
624    }
625
626    /// Set the `strftime` pattern for crosshair and tooltip dates; see
627    /// [`DefaultTimeFormatter::date_format`].
628    pub fn with_date_format(mut self, date_format: impl Into<String>) -> Self {
629        self.date_format = Some(date_format.into());
630        self
631    }
632
633    /// Format with `formatter`, with per-tick access to the locale and default text.
634    ///
635    /// The time zone and clock visibility still apply: `formatter` receives
636    /// wall-clock times in the chosen zone, and dates in place of intraday
637    /// clocks when the time is hidden. Its own options replace the 24-hour,
638    /// seconds, locale and date format settings, unless
639    /// [`format_tick`](TimeFormatter::format_tick) returns `None` for a tick.
640    pub fn with_formatter(mut self, formatter: Box<dyn TimeFormatter>) -> Self {
641        self.formatter = Some(formatter);
642        self
643    }
644
645    /// Set timezone for conversion
646    pub fn with_timezone(mut self, timezone: TimezoneMode) -> Self {
647        self.timezone = Some(timezone);
648        self
649    }
650
651    /// Build the formatter
652    pub fn build(self) -> Box<dyn TimeFormatter> {
653        let base: Box<dyn TimeFormatter> = if let Some(locale) = &self.locale {
654            Box::new(LocaleTimeFormatter {
655                locale: locale.clone(),
656                use_24_hour: self.use_24_hour,
657                show_seconds: self.show_seconds,
658                date_format: self.date_format,
659            })
660        } else {
661            DefaultTimeFormatter {
662                use_24_hour: self.use_24_hour,
663                show_seconds: self.show_seconds,
664                date_format: self.date_format,
665            }
666            .into_box()
667        };
668        let base: Box<dyn TimeFormatter> = match self.formatter {
669            Some(formatter) => Box::new(TickContextFormatter {
670                formatter,
671                fallback: base,
672                locale: self.locale,
673            }),
674            None => base,
675        };
676
677        // UTC and system-local formatters need no storage for a selected zone.
678        match self.timezone.unwrap_or_default() {
679            TimezoneMode::Utc => {
680                Box::new(TimezoneAwareFormatter::new(base, Utc, self.time_visible))
681            }
682            TimezoneMode::Local => Box::new(TimezoneAwareFormatter::new(
683                base,
684                chrono::Local,
685                self.time_visible,
686            )),
687            TimezoneMode::Timezone(zone) | TimezoneMode::Exchange(zone) => {
688                Box::new(TimezoneAwareFormatter::new(base, zone, self.time_visible))
689            }
690        }
691    }
692}
693
694impl Default for TimeFormatterBuilder {
695    fn default() -> Self {
696        Self::new()
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use chrono::TimeZone;
704
705    #[test]
706    fn test_default_formatter() {
707        let formatter = DefaultTimeFormatter::default().clone_box();
708        let time = Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 45).unwrap();
709
710        assert_eq!(formatter.format(time, TickMarkType::Year), "2024");
711        assert_eq!(formatter.format(time, TickMarkType::Month), "Jun");
712        assert_eq!(formatter.format(time, TickMarkType::Time), "14:30");
713        assert_eq!(
714            formatter.format(time, TickMarkType::TimeWithSeconds),
715            "14:30:45"
716        );
717
718        let twelve_hour = TimeFormatterBuilder::new()
719            .with_24_hour(false)
720            .with_seconds(false)
721            .build();
722        assert_eq!(twelve_hour.format(time, TickMarkType::Time), "02:30 PM");
723    }
724
725    #[test]
726    fn fractional_crosshair_seconds_precede_the_meridiem() {
727        let time = Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 45).unwrap()
728            + chrono::Duration::milliseconds(123);
729        for (use_24_hour, seconds, fractional) in [
730            (true, "14:30:45", "14:30:45.123"),
731            (false, "02:30:45 PM", "02:30:45.123 PM"),
732        ] {
733            let formatter = DefaultTimeFormatter {
734                use_24_hour,
735                ..Default::default()
736            };
737            let custom = CustomTimeFormatter::new(move |time, kind| {
738                DefaultTimeFormatter {
739                    use_24_hour,
740                    ..Default::default()
741                }
742                .format(time, kind)
743            });
744            // The style and the trait default share the clock's precision and hour cycle.
745            for formatter in [&formatter as &dyn TimeFormatter, &custom] {
746                for (bar_seconds, clock) in [(0, fractional), (1, seconds)] {
747                    let label = formatter.format_crosshair(time, bar_seconds);
748                    assert!(label.ends_with(clock), "{label}");
749                }
750            }
751        }
752        let hidden_seconds = DefaultTimeFormatter {
753            use_24_hour: false,
754            show_seconds: false,
755            ..Default::default()
756        };
757        assert!(
758            hidden_seconds
759                .format_crosshair(time, 0)
760                .ends_with("02:30 PM")
761        );
762    }
763
764    #[test]
765    fn locale_and_date_format_shape_labels() {
766        // Wednesday 13 March 2024, 14:30:45 UTC.
767        let time = Utc.with_ymd_and_hms(2024, 3, 13, 14, 30, 45).unwrap();
768        // Without the `locales` feature every locale writes English names.
769        let localized = |name, english| {
770            if cfg!(feature = "locales") {
771                name
772            } else {
773                english
774            }
775        };
776        for (locale, expected) in [
777            ("en-US", "02:30 PM"),
778            ("en_US", "02:30 PM"),
779            ("en-CA", "02:30 PM"),
780            ("en_CA", "02:30 PM"),
781            ("en-GB", "14:30"),
782            ("en_GB", "14:30"),
783            ("de-DE", "14:30"),
784            ("de_DE", "14:30"),
785        ] {
786            assert_eq!(
787                LocaleTimeFormatter::new(locale).format(time, TickMarkType::Time),
788                expected,
789                "{locale}"
790            );
791        }
792        let german = LocaleTimeFormatter::new("de-DE");
793        assert_eq!(
794            german.format(time, TickMarkType::Month),
795            localized("Mär", "Mar")
796        );
797        assert_eq!(
798            german.format_crosshair(time, 60),
799            localized("Mi 13 Mär '24 14:30", "Wed 13 Mar '24 14:30")
800        );
801        let japanese = LocaleTimeFormatter::new("ja_JP");
802        assert_eq!(
803            japanese.format(time, TickMarkType::Month),
804            localized("3月", "Mar")
805        );
806        assert_eq!(
807            LocaleTimeFormatter::new("xx-YY").format(time, TickMarkType::Month),
808            "Mar"
809        );
810        let dated = |date_format: &str| {
811            DefaultTimeFormatter {
812                date_format: Some(date_format.into()),
813                ..Default::default()
814            }
815            .clone_box()
816        };
817        assert_eq!(
818            dated("%d.%m.%Y").format_crosshair(time, 60),
819            "13.03.2024 14:30"
820        );
821        assert_eq!(
822            dated("%d.%m.%Y").format_crosshair(time, 86_400),
823            "13.03.2024"
824        );
825        // An invalid pattern keeps the built-in date rather than failing to format.
826        assert_eq!(
827            dated("%Q").format_crosshair(time, 60),
828            "Wed 13 Mar '24 14:30"
829        );
830        let current = time.with_year(Utc::now().year()).unwrap();
831        let previous = current.with_year(current.year() - 1).unwrap();
832        let default = DefaultTimeFormatter::default().clone_box();
833        assert_eq!(default.format_crosshair(current, 86_400), "Mar 13");
834        assert_eq!(
835            default.format_crosshair(previous, 86_400),
836            format!("Mar 13, {}", previous.year())
837        );
838    }
839
840    #[test]
841    fn test_relative_formatter() {
842        let reference = Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
843        let formatter = RelativeTimeFormatter::new(reference);
844
845        let time = Utc.with_ymd_and_hms(2024, 6, 15, 14, 28, 0).unwrap();
846        assert_eq!(formatter.format(time, TickMarkType::Time), "2m ago");
847
848        let time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 30, 0).unwrap();
849        assert_eq!(formatter.format(time, TickMarkType::Time), "2h ago");
850    }
851
852    #[test]
853    fn builder_overrides_host_calendar_and_clock_visibility() {
854        #[derive(Clone)]
855        struct Host;
856        impl TimeFormatter for Host {
857            fn format(&self, time: DateTime<Utc>, _: TickMarkType) -> String {
858                time.to_rfc3339()
859            }
860            fn wall_time(&self, time: DateTime<Utc>) -> DateTime<Utc> {
861                time + chrono::Duration::hours(1)
862            }
863            fn utc_time(&self, _: DateTime<Utc>) -> Option<DateTime<Utc>> {
864                None
865            }
866            fn time_visible(&self) -> bool {
867                false
868            }
869            fn clone_box(&self) -> Box<dyn TimeFormatter> {
870                Box::new(self.clone())
871            }
872        }
873        let time = Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
874        let formatter = TimeFormatterBuilder::new()
875            .with_formatter(Box::new(Host))
876            .with_timezone(TimezoneMode::Utc)
877            .build();
878        for formatter in [formatter.clone_box(), formatter] {
879            assert!(formatter.time_visible());
880            assert_eq!(formatter.wall_time(time), time);
881            assert_eq!(formatter.utc_time(time), Some(time));
882            assert_eq!(
883                formatter.format(time, TickMarkType::Time),
884                time.to_rfc3339()
885            );
886        }
887    }
888
889    #[test]
890    fn test_timezone_conversion_utc_and_fixed_offsets() {
891        let time = Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
892        for (name, label, wall) in [
893            ("UTC", "14:30", "2024-06-15T14:30:00Z"),
894            ("+05:45", "20:15", "2024-06-15T20:15:00Z"),
895            ("-03:30", "11:00", "2024-06-15T11:00:00Z"),
896            ("+14:00", "04:30", "2024-06-16T04:30:00Z"),
897            ("-12:00", "02:30", "2024-06-15T02:30:00Z"),
898            ("+00:19:32", "14:49", "2024-06-15T14:49:32Z"),
899            ("+23:59:59", "14:29", "2024-06-16T14:29:59Z"),
900            ("-23:59:59", "14:30", "2024-06-14T14:30:01Z"),
901        ] {
902            let mode = TimezoneMode::Timezone(name.parse().unwrap());
903            let json = serde_json::to_string(&mode).unwrap();
904            assert_eq!(json, format!(r#"{{"Timezone":"{name}"}}"#));
905            assert_eq!(serde_json::from_str::<TimezoneMode>(&json).unwrap(), mode);
906            let formatter = TimeFormatterBuilder::new()
907                .with_24_hour(true)
908                .with_seconds(false)
909                .with_timezone(mode)
910                .build();
911            assert_eq!(formatter.format(time, TickMarkType::Time), label, "{name}");
912            assert_eq!(
913                formatter.wall_time(time),
914                wall.parse::<DateTime<Utc>>().unwrap(),
915                "{name}"
916            );
917            assert_eq!(formatter.utc_time(formatter.wall_time(time)), Some(time));
918        }
919        for invalid in [
920            "",
921            "+24:00",
922            "-24:00",
923            "+01:60",
924            "+01:00:60",
925            "+01:00junk",
926            "+1:00",
927            "unknown",
928        ] {
929            assert!(invalid.parse::<TimezoneId>().is_err(), "{invalid}");
930        }
931        for seconds in [i32::MIN, -86_400, 86_400, i32::MAX] {
932            assert!(TimezoneId::fixed_offset(seconds).is_none());
933        }
934        assert_eq!(
935            TimezoneId::named("America/New_York").is_some(),
936            cfg!(feature = "timezones")
937        );
938    }
939
940    #[cfg(feature = "timezones")]
941    #[test]
942    fn named_zones_keep_their_clock_through_dst_and_cloning() {
943        use TickMarkType::{Time, TimeWithSeconds};
944        // UTC offsets: New York -4 in summer, -5 in winter; London +1/0; Tokyo +9.
945        for (zone, instant, kind, expected) in [
946            (TimezoneMode::nyse(), "2024-06-15T14:30:00Z", Time, "10:30"),
947            (
948                TimezoneMode::Exchange("America/New_York".parse().unwrap()),
949                "2024-03-10T06:00:00Z",
950                TimeWithSeconds,
951                "01:00:00",
952            ),
953            (
954                TimezoneMode::Exchange("America/New_York".parse().unwrap()),
955                "2024-03-10T08:00:00Z",
956                TimeWithSeconds,
957                "04:00:00",
958            ),
959            (
960                TimezoneMode::Exchange("America/New_York".parse().unwrap()),
961                "2024-11-03T05:00:00Z",
962                TimeWithSeconds,
963                "01:00:00",
964            ),
965            (
966                TimezoneMode::Exchange("America/New_York".parse().unwrap()),
967                "2024-11-03T07:00:00Z",
968                TimeWithSeconds,
969                "02:00:00",
970            ),
971            (TimezoneMode::lse(), "2024-06-15T14:30:00Z", Time, "15:30"),
972            (TimezoneMode::lse(), "2024-01-15T14:30:00Z", Time, "14:30"),
973            (TimezoneMode::jse(), "2024-06-15T14:30:00Z", Time, "23:30"),
974        ] {
975            let time = instant.parse::<DateTime<Utc>>().unwrap();
976            let formatter = TimeFormatterBuilder::new()
977                .with_24_hour(true)
978                .with_timezone(zone.clone())
979                .build();
980            for formatter in [formatter.clone_box(), formatter] {
981                assert_eq!(formatter.format(time, kind), expected, "{zone:?} {instant}");
982            }
983        }
984        let time = Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap();
985        let formatter = TimeFormatterBuilder::new()
986            .with_timezone(TimezoneMode::nyse())
987            .build();
988        assert_eq!(
989            formatter.format_crosshair(time, 3600),
990            "Sat 15 Jun '24 10:30"
991        );
992        assert_eq!(
993            formatter.format_crosshair(time + chrono::Duration::milliseconds(123), 0),
994            "Sat 15 Jun '24 10:30:00.123"
995        );
996    }
997}