Skip to main content

herogpui_components/
calendar_system.rs

1//! The calendar system a month grid is drawn in.
2//!
3//! v3 does not take the calendar as a prop. It reads it from the locale, which
4//! `I18nProvider` supplies: `locale="hi-IN-u-ca-indian"` draws the Indian
5//! calendar, and its "International Calendars" examples are exactly that. This
6//! port has no provider, so it reads the same `-u-ca-` extension from the
7//! locale the operating system reports -- the same preference chain the month
8//! names and the first weekday already follow.
9//!
10//! [`Date`] stays proleptic-Gregorian throughout. It is the value a caller
11//! selects, submits and reads back, and v3 keeps that ISO too; only the *grid*
12//! moves. So every cell converts to a Gregorian [`Date`] the moment it is
13//! built, and constraints, selection and callbacks never see another system.
14//!
15//! When the resolved system is Gregorian -- the default, and every locale that
16//! names no calendar -- each function short-circuits to the plain arithmetic in
17//! [`crate::calendar`]. That is what keeps the common path free of ICU and its
18//! behaviour bit-for-bit unchanged.
19
20use std::sync::OnceLock;
21
22use icu_calendar::{types, AnyCalendar, AnyCalendarKind, Date as IcuDate, Gregorian, Ref};
23use icu_locale_core::Locale as IcuLocale;
24
25use crate::calendar::{days_in_month, first_weekday_pub, Date};
26
27/// A resolved calendar system, and the ICU data to compute in it.
28#[derive(Clone)]
29pub struct CalendarSystem {
30    kind: AnyCalendarKind,
31    calendar: AnyCalendar,
32    /// The tag this system was resolved from, so a heading can be formatted in
33    /// the same locale that chose the calendar.
34    locale: String,
35}
36
37impl CalendarSystem {
38    /// The system named by one locale tag, or `None` if the tag is unparseable.
39    pub(crate) fn for_locale(tag: &str) -> Option<Self> {
40        let locale = tag.parse::<IcuLocale>().ok()?;
41        let kind = AnyCalendarKind::new((&locale).into());
42        Some(Self {
43            kind,
44            calendar: AnyCalendar::new(kind),
45            locale: tag.to_owned(),
46        })
47    }
48
49    /// The locale tag this system came from.
50    pub fn locale(&self) -> &str {
51        &self.locale
52    }
53
54    /// Whether this is the plain Gregorian calendar, where every conversion is
55    /// the identity and the arithmetic in [`crate::calendar`] already applies.
56    pub fn is_gregorian(&self) -> bool {
57        self.kind == AnyCalendarKind::Gregorian
58    }
59
60    fn icu(&self, date: Date) -> Option<IcuDate<Ref<'_, AnyCalendar>>> {
61        let month = u8::try_from(date.month).ok()?;
62        let day = u8::try_from(date.day).ok()?;
63        Some(
64            IcuDate::try_new_gregorian(date.year, month, day)
65                .ok()?
66                .to_calendar(Ref(&self.calendar)),
67        )
68    }
69
70    /// The (year, month, day) one Gregorian date has in this system.
71    pub fn from_gregorian(&self, date: Date) -> (i32, u32, u32) {
72        if self.is_gregorian() {
73            return (date.year, date.month, date.day);
74        }
75        self.icu(date)
76            .map_or((date.year, date.month, date.day), |d| {
77                (
78                    d.year().extended_year(),
79                    u32::from(d.month().ordinal),
80                    u32::from(d.day_of_month().0),
81                )
82            })
83    }
84
85    /// The Gregorian date one (year, month, day) in this system names.
86    ///
87    /// `None` when the triple does not exist -- a 31st in a 30-day month, or a
88    /// month this system's year does not have.
89    pub fn to_gregorian(&self, year: i32, month: u32, day: u32) -> Option<Date> {
90        if self.is_gregorian() {
91            return ((1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month))
92                .then(|| Date::new(year, month, day));
93        }
94        let date = self.date_in(year, month, day)?.to_calendar(Gregorian);
95        Some(Date::new(
96            date.year().extended_year(),
97            u32::from(date.month().ordinal),
98            u32::from(date.day_of_month().0),
99        ))
100    }
101
102    /// Days in one month of this system.
103    pub fn days_in_month(&self, year: i32, month: u32) -> u32 {
104        if self.is_gregorian() {
105            return days_in_month(year, month);
106        }
107        self.date_in(year, month, 1).map_or_else(
108            || days_in_month(year, month),
109            |d| u32::from(d.days_in_month()),
110        )
111    }
112
113    /// Months in one year of this system. Lunisolar years gain a leap month.
114    pub fn months_in_year(&self, year: i32) -> u32 {
115        if self.is_gregorian() {
116            return 12;
117        }
118        self.date_in(year, 1, 1)
119            .map_or(12, |d| u32::from(d.months_in_year()))
120    }
121
122    /// The weekday the 1st of this month falls on, Monday as 0 -- the same
123    /// convention as [`first_weekday_pub`].
124    pub fn first_weekday(&self, year: i32, month: u32) -> usize {
125        if self.is_gregorian() {
126            return first_weekday_pub(year, month);
127        }
128        self.date_in(year, month, 1).map_or_else(
129            || first_weekday_pub(year, month),
130            |d| (d.weekday() as usize + 6) % 7,
131        )
132    }
133
134    /// The (year, month) `delta` months away in this system.
135    ///
136    /// Years do not all hold twelve months -- a lunisolar year gains a leap
137    /// month -- so this steps one month at a time rather than dividing, and
138    /// asks the system how long each year it crosses actually is.
139    pub fn add_months(&self, year: i32, month: u32, delta: i32) -> (i32, u32) {
140        if self.is_gregorian() {
141            return crate::calendar::add_months(year, month, delta);
142        }
143        let (mut year, mut month) = (year, month);
144        for _ in 0..delta.abs() {
145            if delta > 0 {
146                if month >= self.months_in_year(year) {
147                    year += 1;
148                    month = 1;
149                } else {
150                    month += 1;
151                }
152            } else if month <= 1 {
153                year -= 1;
154                month = self.months_in_year(year);
155            } else {
156                month -= 1;
157            }
158        }
159        (year, month)
160    }
161
162    /// Move a Gregorian date by years in this calendar, preserving its month code.
163    pub(crate) fn add_years(&self, date: Date, years: i32) -> Date {
164        if self.is_gregorian() {
165            let year = date.year + years;
166            return Date::new(
167                year,
168                date.month,
169                date.day.min(days_in_month(year, date.month)),
170            );
171        }
172        let Some(mut value) = self.icu(date) else {
173            return date;
174        };
175        if value
176            .try_add_with_options(
177                types::DateDuration {
178                    years: years.unsigned_abs(),
179                    is_negative: years < 0,
180                    ..Default::default()
181                },
182                Default::default(),
183            )
184            .is_err()
185        {
186            return date;
187        }
188        let value = value.to_calendar(Gregorian);
189        Date::new(
190            value.year().extended_year(),
191            u32::from(value.month().ordinal),
192            u32::from(value.day_of_month().0),
193        )
194    }
195
196    fn date_in(&self, year: i32, month: u32, day: u32) -> Option<IcuDate<Ref<'_, AnyCalendar>>> {
197        let mut fields = types::DateFields::default();
198        fields.extended_year = Some(year);
199        fields.ordinal_month = Some(u8::try_from(month).ok()?);
200        fields.day = Some(u8::try_from(day).ok()?);
201        IcuDate::try_from_fields(fields, Default::default(), Ref(&self.calendar)).ok()
202    }
203}
204
205/// The system the running locale names, resolved once.
206///
207/// Gregorian unless a locale in the platform's date preference chain carries a
208/// `-u-ca-` extension naming another one.
209pub fn system() -> &'static CalendarSystem {
210    static SYSTEM: OnceLock<CalendarSystem> = OnceLock::new();
211    SYSTEM.get_or_init(|| {
212        crate::date_constraints::system_locale_tags()
213            .iter()
214            .find_map(|tag| CalendarSystem::for_locale(tag))
215            .unwrap_or_else(|| {
216                CalendarSystem::for_locale("en-US").expect("en-US is a valid locale")
217            })
218    })
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn year_steps_preserve_calendar_month_codes_and_constrain_leap_days() {
227        let gregorian = CalendarSystem::for_locale("en-US").unwrap();
228        assert_eq!(
229            gregorian.add_years(Date::new(2024, 2, 29), 1),
230            Date::new(2025, 2, 28)
231        );
232        let hebrew = CalendarSystem::for_locale("en-US-u-ca-hebrew").unwrap();
233        assert_eq!(
234            hebrew.add_years(Date::new(2024, 3, 25), 1),
235            Date::new(2025, 3, 15)
236        );
237        assert_eq!(
238            hebrew.add_years(Date::new(2025, 3, 15), -1),
239            Date::new(2024, 3, 25)
240        );
241    }
242
243    #[test]
244    fn leap_month_ordinals_round_trip_without_becoming_month_codes() {
245        let system = CalendarSystem::for_locale("en-US-u-ca-hebrew").unwrap();
246        for date in [
247            Date::new(2024, 2, 20),
248            Date::new(2024, 3, 25),
249            Date::new(2024, 4, 25),
250            Date::new(2024, 9, 25),
251        ] {
252            let (year, month, day) = system.from_gregorian(date);
253            assert_eq!(system.to_gregorian(year, month, day), Some(date));
254        }
255        assert_eq!(system.days_in_month(5784, 6), 30);
256        assert_eq!(system.days_in_month(5784, 7), 29);
257    }
258
259    fn indian() -> CalendarSystem {
260        CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap()
261    }
262
263    #[test]
264    fn a_locale_without_an_extension_is_gregorian() {
265        assert!(CalendarSystem::for_locale("en-US").unwrap().is_gregorian());
266        assert!(CalendarSystem::for_locale("de-DE").unwrap().is_gregorian());
267        assert!(!indian().is_gregorian());
268        assert!(!CalendarSystem::for_locale("th-TH-u-ca-buddhist")
269            .unwrap()
270            .is_gregorian());
271    }
272
273    #[test]
274    fn the_gregorian_path_matches_the_plain_arithmetic() {
275        let system = CalendarSystem::for_locale("en-US").unwrap();
276        for (year, month) in [(2026, 1), (2024, 2), (2025, 2), (2026, 4), (2026, 12)] {
277            assert_eq!(
278                system.days_in_month(year, month),
279                days_in_month(year, month)
280            );
281            assert_eq!(
282                system.first_weekday(year, month),
283                first_weekday_pub(year, month)
284            );
285            assert_eq!(system.months_in_year(year), 12);
286        }
287        let date = Date::new(2026, 1, 15);
288        assert_eq!(system.from_gregorian(date), (2026, 1, 15));
289        assert_eq!(system.to_gregorian(2026, 1, 15), Some(date));
290    }
291
292    #[test]
293    fn a_non_gregorian_system_reports_its_own_year_and_month() {
294        // 15 January 2026 is 25 Pausha 1947 in the Indian national calendar,
295        // whose tenth month runs 30 days.
296        let system = indian();
297        assert_eq!(
298            system.from_gregorian(Date::new(2026, 1, 15)),
299            (1947, 10, 25)
300        );
301        assert_eq!(system.days_in_month(1947, 10), 30);
302        assert_eq!(system.months_in_year(1947), 12);
303    }
304
305    #[test]
306    fn conversion_round_trips_through_the_other_system() {
307        let system = indian();
308        for date in [
309            Date::new(2026, 1, 15),
310            Date::new(2026, 3, 22),
311            Date::new(2024, 2, 29),
312            Date::new(1999, 12, 31),
313        ] {
314            let (year, month, day) = system.from_gregorian(date);
315            assert_eq!(
316                system.to_gregorian(year, month, day),
317                Some(date),
318                "{date:?} must survive the trip through the Indian calendar"
319            );
320        }
321    }
322
323    #[test]
324    fn an_impossible_day_reports_nothing_rather_than_clamping() {
325        let gregorian = CalendarSystem::for_locale("en-US").unwrap();
326        assert_eq!(
327            gregorian.to_gregorian(2025, 2, 29),
328            None,
329            "2025 is not a leap year"
330        );
331        assert_eq!(
332            gregorian.to_gregorian(2026, 13, 1),
333            None,
334            "there is no 13th month"
335        );
336        assert_eq!(
337            gregorian.to_gregorian(2026, 4, 31),
338            None,
339            "April has 30 days"
340        );
341        // The Indian calendar's tenth month has 30 days, so the 31st is not a
342        // date and must not silently become the 1st of the next one.
343        assert_eq!(indian().to_gregorian(1947, 10, 31), None);
344    }
345
346    #[test]
347    fn stepping_months_wraps_each_system_own_year() {
348        let gregorian = CalendarSystem::for_locale("en-US").unwrap();
349        assert_eq!(gregorian.add_months(2026, 12, 1), (2027, 1));
350        assert_eq!(gregorian.add_months(2026, 1, -1), (2025, 12));
351        assert_eq!(gregorian.add_months(2026, 8, 5), (2027, 1));
352
353        // Stepping past the end of an Indian year must land in the next one,
354        // and stepping back out of it must land on that year's last month.
355        let system = indian();
356        let months = system.months_in_year(1947);
357        assert_eq!(system.add_months(1947, months, 1), (1948, 1));
358        assert_eq!(system.add_months(1948, 1, -1), (1947, months));
359    }
360
361    #[test]
362    fn stepping_a_month_moves_a_real_day_by_a_real_month() {
363        // The step is only meaningful if the month it lands on exists: the 1st
364        // of the next month must be a date, and a later one than this month's.
365        let system = indian();
366        let (year, month) = system.add_months(1947, 10, 1);
367        let this = system.to_gregorian(1947, 10, 1).unwrap();
368        let next = system.to_gregorian(year, month, 1).unwrap();
369        assert!(
370            crate::calendar::days_from_civil(&next) > crate::calendar::days_from_civil(&this),
371            "{next:?} must follow {this:?}"
372        );
373    }
374
375    #[test]
376    fn the_first_weekday_is_monday_indexed_in_both_systems() {
377        // 1 Pausha 1947 and its Gregorian equivalent are the same actual day,
378        // so they must report the same weekday whichever system asks.
379        let system = indian();
380        let first = system.to_gregorian(1947, 10, 1).unwrap();
381        assert_eq!(
382            system.first_weekday(1947, 10),
383            crate::calendar::weekday_index(first)
384        );
385    }
386}