Skip to main content

ui/
i18n_locale.rs

1// i18n_locale -- regional formats (a compact subset of CLDR) and plural categories
2//
3//   Locale::get(tag) -> Locale          the best match of a BCP 47 tag: the tag itself, the
4//                                       language with the region, the language's default
5//                                       ("de" -> de-DE, "en" -> en-US), else "en-001"
6//   locale() / set_locale(tag) / locale_tag()   the user's format: ui boots it from
7//                                       jfgui_LANG, the system (window mode) or "de-DE"
8//                                       (headless runs, so shots match on every machine)
9//   loc.fmt_int(n)                      "1.234.567" (de), "1,234,567" (en), "12,34,567" (hi)
10//   loc.fmt_num(v, decimals)            fixed decimals, rounded; grouped
11//   loc.fmt_percent(fraction, decimals) 0.25 -> "25 %" (de), "25%" (en), "%25" (tr)
12//   loc.fmt_currency(v, "USD") / fmt_money(v)   "1.234,56 $" (de), "$1,234.56" (en-US);
13//                                       fmt_money in the locale's own currency
14//   loc.fmt_size(bytes)                 "1,5 MB" (1024 based; French "Mo")
15//   loc.fmt_date(&civil, DATE_SHORT | DATE_LONG) / fmt_time(&civil) / fmt_date_time(&civil)
16//                                       "17.10.2026" / "17. Oktober 2026" / "19:58";
17//                                       en-US "10/17/2026" / "October 17, 2026" / "7:58 PM"
18//   loc.first_weekday() (0 = Monday .. 6 = Sunday), rtl(), decimal(), group(), currency(), tag()
19//   fmt_int / fmt_num / fmt_percent / fmt_currency / fmt_money / fmt_size / fmt_date /
20//   fmt_time / fmt_date_time            the same with locale()
21//   plural(lang, i, v, f) / plural_int(lang, n) -> Plural   CLDR cardinal categories (i the
22//                                       integer digits, v the number of visible fraction
23//                                       digits, f those digits as an integer)
24//
25// Numbers use ASCII digits everywhere (also Arabic and Hindi); spaces inside numbers and
26// before units are no-break spaces (U+00A0, French U+202F).  Long dates use the format
27// (genitive) month names of the locale's language.
28
29use alloc::format;
30use alloc::string::String;
31use alloc::vec::Vec;
32use datetime::Civil;
33
34const NBSP: char = '\u{A0}';
35const NNBSP: char = '\u{202F}';
36
37/// fmt_date: "17.10.2026" / "10/17/2026"
38pub const DATE_SHORT: u32 = 0;
39/// fmt_date: "17. Oktober 2026" / "October 17, 2026"
40pub const DATE_LONG: u32 = 1;
41
42// currency pattern
43const CUR_PRE: u8 = 0; // "$1.00"
44const CUR_PRE_SP: u8 = 1; // "€ 1,00"
45const CUR_SUF: u8 = 2; // "1,00 €"
46// percent pattern
47const PCT_SUF: u8 = 0; // "5%"
48const PCT_SUF_SP: u8 = 1; // "5 %" (U+00A0)
49const PCT_SUF_NN: u8 = 2; // "5 %" (U+202F)
50const PCT_PRE: u8 = 3; // "%5"
51
52#[derive(Clone, Copy)]
53struct Data {
54    tag: &'static str,
55    dec: char,
56    grp: char,
57    /// the size of the groups after the first (3; India 2)
58    grp2: u8,
59    /// 2: four-digit numbers stay ungrouped ("1234", "12 345")
60    min_grp: u8,
61    date: &'static str,
62    date_long: &'static str,
63    time: &'static str,
64    dt_sep: &'static str,
65    months: &'static [&'static str; 12],
66    am_pm: [&'static str; 2],
67    /// 0 = Monday .. 6 = Sunday
68    first_day: u8,
69    cur: &'static str,
70    cur_pat: u8,
71    pct: u8,
72    rtl: bool,
73}
74
75const M_EN: [&str; 12] =
76    ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
77const M_DE: [&str; 12] =
78    ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
79const M_FR: [&str; 12] =
80    ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"];
81const M_ES: [&str; 12] =
82    ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"];
83const M_IT: [&str; 12] =
84    ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"];
85const M_NL: [&str; 12] =
86    ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"];
87const M_PT: [&str; 12] =
88    ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"];
89const M_PL: [&str; 12] = [
90    "stycznia", "lutego", "marca", "kwietnia", "maja", "czerwca", "lipca", "sierpnia", "września", "października", "listopada", "grudnia",
91];
92const M_CS: [&str; 12] =
93    ["ledna", "února", "března", "dubna", "května", "června", "července", "srpna", "září", "října", "listopadu", "prosince"];
94const M_SK: [&str; 12] =
95    ["januára", "februára", "marca", "apríla", "mája", "júna", "júla", "augusta", "septembra", "októbra", "novembra", "decembra"];
96const M_SV: [&str; 12] =
97    ["januari", "februari", "mars", "april", "maj", "juni", "juli", "augusti", "september", "oktober", "november", "december"];
98const M_DA: [&str; 12] =
99    ["januar", "februar", "marts", "april", "maj", "juni", "juli", "august", "september", "oktober", "november", "december"];
100const M_NB: [&str; 12] =
101    ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
102const M_FI: [&str; 12] = [
103    "tammikuuta", "helmikuuta", "maaliskuuta", "huhtikuuta", "toukokuuta", "kesäkuuta", "heinäkuuta", "elokuuta", "syyskuuta",
104    "lokakuuta", "marraskuuta", "joulukuuta",
105];
106const M_RU: [&str; 12] =
107    ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"];
108const M_UK: [&str; 12] =
109    ["січня", "лютого", "березня", "квітня", "травня", "червня", "липня", "серпня", "вересня", "жовтня", "листопада", "грудня"];
110const M_TR: [&str; 12] = ["Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"];
111const M_EL: [&str; 12] = [
112    "Ιανουαρίου", "Φεβρουαρίου", "Μαρτίου", "Απριλίου", "Μαΐου", "Ιουνίου", "Ιουλίου", "Αυγούστου", "Σεπτεμβρίου", "Οκτωβρίου",
113    "Νοεμβρίου", "Δεκεμβρίου",
114];
115const M_HU: [&str; 12] =
116    ["január", "február", "március", "április", "május", "június", "július", "augusztus", "szeptember", "október", "november", "december"];
117const M_RO: [&str; 12] = [
118    "ianuarie", "februarie", "martie", "aprilie", "mai", "iunie", "iulie", "august", "septembrie", "octombrie", "noiembrie", "decembrie",
119];
120const M_ID: [&str; 12] =
121    ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"];
122const M_AR: [&str; 12] = ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"];
123const M_HE: [&str; 12] = ["ינואר", "פברואר", "מרץ", "אפריל", "מאי", "יוני", "יולי", "אוגוסט", "ספטמבר", "אוקטובר", "נובמבר", "דצמבר"];
124const M_HI: [&str; 12] = ["जनवरी", "फ़रवरी", "मार्च", "अप्रैल", "मई", "जून", "जुलाई", "अगस्त", "सितंबर", "अक्तूबर", "नवंबर", "दिसंबर"];
125
126const AM_PM: [&str; 2] = ["AM", "PM"];
127
128const DE: Data = Data {
129    tag: "de-DE",
130    dec: ',',
131    grp: '.',
132    grp2: 3,
133    min_grp: 1,
134    date: "dd.MM.yyyy",
135    date_long: "d. MMMM yyyy",
136    time: "HH:mm",
137    dt_sep: ", ",
138    months: &M_DE,
139    am_pm: AM_PM,
140    first_day: 0,
141    cur: "EUR",
142    cur_pat: CUR_SUF,
143    pct: PCT_SUF_SP,
144    rtl: false,
145};
146const EN: Data = Data {
147    tag: "en-US",
148    dec: '.',
149    grp: ',',
150    date: "M/d/yyyy",
151    date_long: "MMMM d, yyyy",
152    time: "h:mm a",
153    months: &M_EN,
154    first_day: 6,
155    cur: "USD",
156    cur_pat: CUR_PRE,
157    pct: PCT_SUF,
158    ..DE
159};
160// English outside the table's regions (CLDR "en-001")
161const EN_001: Data = Data { tag: "en-001", date: "dd/MM/yyyy", date_long: "d MMMM yyyy", time: "HH:mm", first_day: 0, ..EN };
162const EN_GB: Data = Data { tag: "en-GB", cur: "GBP", ..EN_001 };
163const FR: Data = Data {
164    tag: "fr-FR",
165    grp: NNBSP,
166    date: "dd/MM/yyyy",
167    date_long: "d MMMM yyyy",
168    dt_sep: " ",
169    months: &M_FR,
170    pct: PCT_SUF_NN,
171    ..DE
172};
173const ES: Data =
174    Data { tag: "es-ES", min_grp: 2, date: "dd/MM/yyyy", date_long: "d 'de' MMMM 'de' yyyy", time: "H:mm", months: &M_ES, ..DE };
175const IT: Data = Data { tag: "it-IT", date: "dd/MM/yyyy", date_long: "d MMMM yyyy", months: &M_IT, pct: PCT_SUF, ..DE };
176const NL: Data = Data {
177    tag: "nl-NL",
178    date: "dd-MM-yyyy",
179    date_long: "d MMMM yyyy",
180    dt_sep: " ",
181    months: &M_NL,
182    cur_pat: CUR_PRE_SP,
183    pct: PCT_SUF,
184    ..DE
185};
186const PT: Data = Data {
187    tag: "pt-BR",
188    date: "dd/MM/yyyy",
189    date_long: "d 'de' MMMM 'de' yyyy",
190    dt_sep: " ",
191    months: &M_PT,
192    first_day: 6,
193    cur: "BRL",
194    cur_pat: CUR_PRE_SP,
195    pct: PCT_SUF,
196    ..DE
197};
198const PL: Data = Data { tag: "pl-PL", grp: NBSP, min_grp: 2, date_long: "d MMMM yyyy", months: &M_PL, cur: "PLN", pct: PCT_SUF, ..DE };
199const CS: Data =
200    Data { tag: "cs-CZ", grp: NBSP, date: "d. M. yyyy", time: "H:mm", dt_sep: " ", months: &M_CS, cur: "CZK", ..DE };
201const SV: Data = Data {
202    tag: "sv-SE",
203    grp: NBSP,
204    date: "yyyy-MM-dd",
205    date_long: "d MMMM yyyy",
206    dt_sep: " ",
207    months: &M_SV,
208    cur: "SEK",
209    ..DE
210};
211const RU: Data =
212    Data { tag: "ru-RU", grp: NBSP, date_long: "d MMMM yyyy 'г.'", months: &M_RU, cur: "RUB", ..DE };
213
214static TABLE: &[Data] = &[
215    // the first entry of a language is its default
216    DE,
217    Data { tag: "de-AT", grp: NBSP, cur_pat: CUR_PRE_SP, ..DE },
218    Data { tag: "de-CH", dec: '.', grp: '’', cur: "CHF", cur_pat: CUR_PRE_SP, pct: PCT_SUF, ..DE },
219    Data { tag: "de-LI", dec: '.', grp: '’', cur: "CHF", cur_pat: CUR_PRE_SP, pct: PCT_SUF, ..DE },
220    EN,
221    EN_GB,
222    Data { tag: "en-IE", cur: "EUR", ..EN_001 },
223    Data { tag: "en-AU", time: "h:mm a", cur: "AUD", ..EN_001 },
224    Data { tag: "en-NZ", date: "d/MM/yyyy", time: "h:mm a", cur: "NZD", ..EN_001 },
225    Data { tag: "en-CA", date: "yyyy-MM-dd", time: "h:mm a", first_day: 6, cur: "CAD", ..EN },
226    Data { tag: "en-IN", grp2: 2, date: "dd/MM/yyyy", date_long: "d MMMM yyyy", first_day: 6, cur: "INR", ..EN },
227    EN_001,
228    FR,
229    Data { tag: "fr-BE", ..FR },
230    Data { tag: "fr-CA", grp: NBSP, date: "yyyy-MM-dd", time: "HH 'h' mm", first_day: 6, cur: "CAD", ..FR },
231    Data { tag: "fr-CH", date: "dd.MM.yyyy", cur: "CHF", ..FR },
232    ES,
233    Data { tag: "es-MX", dec: '.', grp: ',', min_grp: 1, first_day: 6, cur: "MXN", cur_pat: CUR_PRE, pct: PCT_SUF, ..ES },
234    Data { tag: "es-US", dec: '.', grp: ',', min_grp: 1, time: "h:mm a", first_day: 6, cur: "USD", cur_pat: CUR_PRE, pct: PCT_SUF, ..ES },
235    IT,
236    Data { tag: "it-CH", dec: '.', grp: '’', date: "dd.MM.yyyy", cur: "CHF", cur_pat: CUR_PRE_SP, ..IT },
237    NL,
238    Data { tag: "nl-BE", date: "d/MM/yyyy", cur_pat: CUR_SUF, ..NL },
239    PT,
240    Data { tag: "pt-PT", grp: NBSP, min_grp: 2, first_day: 0, cur: "EUR", cur_pat: CUR_SUF, ..PT },
241    PL,
242    CS,
243    Data { tag: "sk-SK", months: &M_SK, cur: "EUR", ..CS },
244    SV,
245    Data { tag: "da-DK", date: "dd.MM.yyyy", date_long: "d. MMMM yyyy", time: "HH.mm", months: &M_DA, cur: "DKK", grp: '.', ..SV },
246    Data { tag: "nb-NO", date: "dd.MM.yyyy", date_long: "d. MMMM yyyy", dt_sep: ", ", months: &M_NB, cur: "NOK", ..SV },
247    Data { tag: "fi-FI", date: "d.M.yyyy", date_long: "d. MMMM yyyy", time: "H.mm", months: &M_FI, cur: "EUR", ..SV },
248    RU,
249    Data { tag: "uk-UA", date_long: "d MMMM yyyy 'р.'", months: &M_UK, cur: "UAH", pct: PCT_SUF, ..RU },
250    Data { tag: "tr-TR", date_long: "d MMMM yyyy", dt_sep: " ", months: &M_TR, cur: "TRY", cur_pat: CUR_PRE, pct: PCT_PRE, ..DE },
251    Data { tag: "el-GR", date: "d/M/yyyy", date_long: "d MMMM yyyy", months: &M_EL, pct: PCT_SUF, ..DE },
252    Data {
253        tag: "hu-HU",
254        grp: NBSP,
255        date: "yyyy. MM. dd.",
256        date_long: "yyyy. MMMM d.",
257        time: "H:mm",
258        dt_sep: " ",
259        months: &M_HU,
260        cur: "HUF",
261        pct: PCT_SUF,
262        ..DE
263    },
264    Data { tag: "ro-RO", date_long: "d MMMM yyyy", months: &M_RO, cur: "RON", ..DE },
265    Data { tag: "ja-JP", date: "yyyy/MM/dd", date_long: "yyyy年M月d日", time: "H:mm", dt_sep: " ", first_day: 6, cur: "JPY", ..EN },
266    Data { tag: "zh-CN", date: "yyyy/M/d", date_long: "yyyy年M月d日", time: "HH:mm", dt_sep: " ", first_day: 0, cur: "CNY", ..EN },
267    Data { tag: "zh-TW", date: "yyyy/M/d", date_long: "yyyy年M月d日", time: "HH:mm", dt_sep: " ", cur: "TWD", ..EN },
268    Data { tag: "ko-KR", date: "yyyy. M. d.", date_long: "yyyy년 M월 d일", time: "HH:mm", dt_sep: " ", cur: "KRW", ..EN },
269    Data {
270        tag: "ar-EG",
271        date: "d/M/yyyy",
272        date_long: "d MMMM yyyy",
273        time: "HH:mm",
274        dt_sep: "، ",
275        months: &M_AR,
276        first_day: 5,
277        cur: "EGP",
278        cur_pat: CUR_SUF,
279        rtl: true,
280        ..EN
281    },
282    Data { tag: "ar-SA", date: "d/M/yyyy", date_long: "d MMMM yyyy", time: "HH:mm", dt_sep: "، ", months: &M_AR, cur: "SAR", cur_pat: CUR_SUF, rtl: true, ..EN },
283    Data {
284        tag: "he-IL",
285        date: "d.M.yyyy",
286        date_long: "d בMMMM yyyy",
287        time: "H:mm",
288        months: &M_HE,
289        cur: "ILS",
290        cur_pat: CUR_SUF,
291        rtl: true,
292        ..EN
293    },
294    Data { tag: "hi-IN", grp2: 2, date: "d/M/yyyy", date_long: "d MMMM yyyy", months: &M_HI, am_pm: ["am", "pm"], cur: "INR", ..EN },
295    Data {
296        tag: "id-ID",
297        dec: ',',
298        grp: '.',
299        date: "dd/MM/yyyy",
300        date_long: "d MMMM yyyy",
301        time: "HH.mm",
302        months: &M_ID,
303        cur: "IDR",
304        ..EN
305    },
306];
307
308// currencies: code, symbol at home, symbol elsewhere, decimals
309static CURRENCIES: &[(&str, &str, &str, u8)] = &[
310    ("EUR", "€", "€", 2),
311    ("USD", "$", "US$", 2),
312    ("GBP", "£", "£", 2),
313    ("CHF", "CHF", "CHF", 2),
314    ("JPY", "¥", "¥", 0),
315    ("CNY", "¥", "CN¥", 2),
316    ("CAD", "$", "CA$", 2),
317    ("AUD", "$", "A$", 2),
318    ("NZD", "$", "NZ$", 2),
319    ("INR", "₹", "₹", 2),
320    ("BRL", "R$", "R$", 2),
321    ("MXN", "$", "MX$", 2),
322    ("PLN", "zł", "PLN", 2),
323    ("CZK", "Kč", "CZK", 2),
324    ("SEK", "kr", "SEK", 2),
325    ("DKK", "kr.", "DKK", 2),
326    ("NOK", "kr", "NOK", 2),
327    ("RUB", "₽", "RUB", 2),
328    ("UAH", "₴", "UAH", 2),
329    ("TRY", "₺", "TRY", 2),
330    ("HUF", "Ft", "HUF", 2),
331    ("RON", "RON", "RON", 2),
332    ("KRW", "₩", "₩", 0),
333    ("TWD", "$", "NT$", 2),
334    ("ILS", "₪", "₪", 2),
335    ("EGP", "ج.م.", "EGP", 2),
336    ("SAR", "ر.س.", "SAR", 2),
337    ("IDR", "Rp", "IDR", 2),
338];
339
340/// the formats of one region (copy it freely)
341#[derive(Clone, Copy)]
342pub struct Locale {
343    d: &'static Data,
344}
345
346impl Locale {
347    /// the best match of a BCP 47 tag or a POSIX locale name (see the module comment)
348    pub fn get(tag: &str) -> Locale {
349        Locale { d: lookup(tag) }
350    }
351
352    /// the tag of the matched data ("de-DE", "en-001")
353    pub fn tag(&self) -> &'static str {
354        self.d.tag
355    }
356    /// the decimal sign
357    pub fn decimal(&self) -> char {
358        self.d.dec
359    }
360    /// the thousands separator
361    pub fn group(&self) -> char {
362        self.d.grp
363    }
364    /// the first day of the week: 0 = Monday .. 6 = Sunday
365    pub fn first_weekday(&self) -> u32 {
366        self.d.first_day as u32
367    }
368    /// right-to-left script (Arabic, Hebrew)
369    pub fn rtl(&self) -> bool {
370        self.d.rtl
371    }
372    /// the ISO 4217 code of the region's currency
373    pub fn currency(&self) -> &'static str {
374        self.d.cur
375    }
376    /// the long (format) name of month m (1..12) in the locale's language
377    pub fn month_name(&self, m: u32) -> &'static str {
378        self.d.months[((m.max(1) - 1) % 12) as usize]
379    }
380
381    // integer digits with the group separators
382    fn group_digits(&self, digits: &str, out: &mut String) {
383        let n = digits.len();
384        if n < 3 + self.d.min_grp as usize {
385            out.push_str(digits);
386            return;
387        }
388        let mut cuts: Vec<usize> = Vec::new();
389        let mut p = n as isize - 3;
390        while p > 0 {
391            cuts.push(p as usize);
392            p -= self.d.grp2.max(1) as isize;
393        }
394        for (k, c) in digits.chars().enumerate() {
395            if cuts.contains(&k) {
396                out.push(self.d.grp);
397            }
398            out.push(c);
399        }
400    }
401
402    /// an integer with group separators
403    pub fn fmt_int(&self, n: i64) -> String {
404        let mut out = String::new();
405        if n < 0 {
406            out.push('-');
407        }
408        self.group_digits(&format!("{}", n.unsigned_abs()), &mut out);
409        out
410    }
411
412    /// a number with exactly `decimals` fraction digits (rounded) and group separators
413    pub fn fmt_num(&self, v: f64, decimals: u32) -> String {
414        self.num(v, decimals, true)
415    }
416
417    pub(crate) fn num(&self, v: f64, decimals: u32, grouped: bool) -> String {
418        if v.is_nan() {
419            return String::from("NaN");
420        }
421        if v.is_infinite() {
422            return String::from(if v < 0.0 { "-∞" } else { "∞" });
423        }
424        let s = format!("{:.*}", decimals.min(17) as usize, if v < 0.0 { -v } else { v });
425        let (int, frac) = s.split_once('.').unwrap_or((&s, ""));
426        let mut out = String::new();
427        if v < 0.0 && s.bytes().any(|b| (b'1'..=b'9').contains(&b)) {
428            out.push('-');
429        }
430        if grouped {
431            self.group_digits(int, &mut out);
432        } else {
433            out.push_str(int);
434        }
435        if !frac.is_empty() {
436            out.push(self.d.dec);
437            out.push_str(frac);
438        }
439        out
440    }
441
442    /// a number as short as it is: up to 6 fraction digits, trailing zeros dropped, no groups
443    pub(crate) fn num_plain(&self, v: f64) -> String {
444        let mut s = self.num(v, 6, false);
445        if s.contains(self.d.dec) {
446            while s.ends_with('0') {
447                s.pop();
448            }
449            if s.ends_with(self.d.dec) {
450                s.pop();
451            }
452        }
453        s
454    }
455
456    /// a fraction as percent: 0.25 -> "25 %" (de) / "25%" (en)
457    pub fn fmt_percent(&self, fraction: f64, decimals: u32) -> String {
458        let n = self.fmt_num(fraction * 100.0, decimals);
459        match self.d.pct {
460            PCT_SUF_SP => format!("{n}{NBSP}%"),
461            PCT_SUF_NN => format!("{n}{NNBSP}%"),
462            PCT_PRE => format!("%{n}"),
463            _ => format!("{n}%"),
464        }
465    }
466
467    /// an amount of the ISO 4217 currency `code` ("EUR", "USD"; unknown codes are shown as is)
468    pub fn fmt_currency(&self, v: f64, code: &str) -> String {
469        let c = CURRENCIES.iter().find(|c| c.0.eq_ignore_ascii_case(code));
470        let (sym, dec) = match c {
471            Some(c) if c.0 == self.d.cur => (c.1, c.3),
472            Some(c) => (c.2, c.3),
473            None => (code, 2),
474        };
475        let n = self.fmt_num(if v < 0.0 { -v } else { v }, dec as u32);
476        let minus = if v < 0.0 && n.bytes().any(|b| (b'1'..=b'9').contains(&b)) { "-" } else { "" };
477        let letter = sym.chars().last().is_some_and(char::is_alphabetic);
478        match self.d.cur_pat {
479            CUR_SUF => format!("{minus}{n}{NBSP}{sym}"),
480            CUR_PRE_SP => format!("{minus}{sym}{NBSP}{n}"),
481            _ if letter => format!("{minus}{sym}{NBSP}{n}"),
482            _ => format!("{minus}{sym}{n}"),
483        }
484    }
485
486    /// an amount in the region's own currency
487    pub fn fmt_money(&self, v: f64) -> String {
488        self.fmt_currency(v, self.d.cur)
489    }
490
491    /// a byte count: "512 B", "1,5 KB", "23 MB" (1024 based, one decimal below 10)
492    pub fn fmt_size(&self, bytes: u64) -> String {
493        let units: [&str; 6] =
494            if self.d.tag.starts_with("fr") { ["o", "ko", "Mo", "Go", "To", "Po"] } else { ["B", "KB", "MB", "GB", "TB", "PB"] };
495        if bytes < 1024 {
496            return format!("{bytes}{NBSP}{}", units[0]);
497        }
498        let mut v = bytes as f64;
499        let mut k = 0;
500        while v >= 1024.0 && k < 5 {
501            v /= 1024.0;
502            k += 1;
503        }
504        format!("{}{NBSP}{}", self.fmt_num(v, if v < 9.95 { 1 } else { 0 }), units[k])
505    }
506
507    /// a date: DATE_SHORT "17.10.2026" / DATE_LONG "17. Oktober 2026"
508    pub fn fmt_date(&self, c: &Civil, style: u32) -> String {
509        pattern(self.d, if style == DATE_LONG { self.d.date_long } else { self.d.date }, c)
510    }
511
512    /// a time of day: "19:58" / "7:58 PM"
513    pub fn fmt_time(&self, c: &Civil) -> String {
514        pattern(self.d, self.d.time, c)
515    }
516
517    /// the short date and the time: "17.10.2026, 19:58" / "10/17/2026, 7:58 PM"
518    pub fn fmt_date_time(&self, c: &Civil) -> String {
519        let mut s = self.fmt_date(c, DATE_SHORT);
520        s.push_str(self.d.dt_sep);
521        s.push_str(&self.fmt_time(c));
522        s
523    }
524}
525
526// a date pattern: d dd M MM MMMM yy yyyy H HH h hh mm ss a, 'quoted text', anything else as is
527fn pattern(d: &Data, pat: &str, c: &Civil) -> String {
528    let mut out = String::new();
529    let b: Vec<char> = pat.chars().collect();
530    let mut i = 0;
531    while i < b.len() {
532        let ch = b[i];
533        if ch == '\'' {
534            let end = b[i + 1..].iter().position(|&x| x == '\'').map_or(b.len(), |p| i + 1 + p);
535            if end == i + 1 {
536                out.push('\''); // '' -> '
537            }
538            out.extend(&b[i + 1..end]);
539            i = end + 1;
540            continue;
541        }
542        if !ch.is_ascii_alphabetic() {
543            out.push(ch);
544            i += 1;
545            continue;
546        }
547        let mut n = 1;
548        while i + n < b.len() && b[i + n] == ch {
549            n += 1;
550        }
551        i += n;
552        let h12 = match c.hour % 12 {
553            0 => 12,
554            h => h,
555        };
556        match (ch, n) {
557            ('d', 1) => out.push_str(&format!("{}", c.day)),
558            ('d', _) => out.push_str(&format!("{:02}", c.day)),
559            ('M', 1) => out.push_str(&format!("{}", c.month)),
560            ('M', 2) => out.push_str(&format!("{:02}", c.month)),
561            ('M', _) => out.push_str(d.months[((c.month.max(1) - 1) % 12) as usize]),
562            ('y', 2) => out.push_str(&format!("{:02}", c.year.rem_euclid(100))),
563            ('y', _) => out.push_str(&format!("{}", c.year)),
564            ('H', 1) => out.push_str(&format!("{}", c.hour)),
565            ('H', _) => out.push_str(&format!("{:02}", c.hour)),
566            ('h', 1) => out.push_str(&format!("{}", h12)),
567            ('h', _) => out.push_str(&format!("{:02}", h12)),
568            ('m', _) => out.push_str(&format!("{:02}", c.minute)),
569            ('s', _) => out.push_str(&format!("{:02}", c.second)),
570            ('a', _) => out.push_str(d.am_pm[(c.hour >= 12) as usize]),
571            _ => {}
572        }
573    }
574    out
575}
576
577fn lookup(tag: &str) -> &'static Data {
578    let Some(t) = plat::locale_tag(tag) else { return &TABLE[index_of("en-001")] };
579    if let Some(d) = TABLE.iter().find(|d| d.tag.eq_ignore_ascii_case(&t)) {
580        return d;
581    }
582    let mut parts = t.split('-');
583    let mut lang = parts.next().unwrap_or("");
584    let rest: Vec<&str> = parts.collect();
585    let script = rest.iter().find(|p| p.len() == 4).copied();
586    let mut region = rest.iter().find(|p| p.len() == 2 || (p.len() == 3 && p.bytes().all(|b| b.is_ascii_digit()))).copied();
587    // old and macro language codes, Chinese scripts
588    lang = match lang {
589        "no" | "nn" => "nb",
590        "iw" => "he",
591        "in" => "id",
592        l => l,
593    };
594    if lang == "zh" {
595        region = match (script, region) {
596            (Some("Hant"), None) | (_, Some("HK" | "MO")) => Some("TW"),
597            (_, r) => r,
598        };
599    }
600    if let Some(r) = region {
601        let want = format!("{lang}-{r}");
602        if let Some(d) = TABLE.iter().find(|d| d.tag.eq_ignore_ascii_case(&want)) {
603            return d;
604        }
605        // English of other regions: the international formats
606        if lang == "en" {
607            return &TABLE[index_of("en-001")];
608        }
609    }
610    TABLE.iter().find(|d| d.tag.split('-').next() == Some(lang)).unwrap_or(&TABLE[index_of("en-001")])
611}
612
613fn index_of(tag: &str) -> usize {
614    TABLE.iter().position(|d| d.tag == tag).unwrap_or(0)
615}
616
617// ---------------------------------------------------------------- the user's locale
618
619static CURRENT: rt::sync::SpinLock<Option<(Locale, String)>> = rt::sync::SpinLock::new(None);
620
621/// the user's regional format (default "de-DE")
622pub fn locale() -> Locale {
623    CURRENT.lock().as_ref().map_or(Locale { d: &TABLE[0] }, |c| c.0)
624}
625
626/// the tag the format was asked for ("de-LU" may use the data of "de-DE")
627pub fn locale_tag() -> String {
628    CURRENT.lock().as_ref().map_or_else(|| String::from("de-DE"), |c| c.1.clone())
629}
630
631/// set the user's regional format (a BCP 47 tag or a POSIX locale name)
632pub fn set_locale(tag: &str) {
633    let t = plat::locale_tag(tag).unwrap_or_else(|| String::from(tag));
634    *CURRENT.lock() = Some((Locale::get(&t), t));
635}
636
637/// Locale::fmt_int in the user's format
638pub fn fmt_int(n: i64) -> String {
639    locale().fmt_int(n)
640}
641/// Locale::fmt_num in the user's format
642pub fn fmt_num(v: f64, decimals: u32) -> String {
643    locale().fmt_num(v, decimals)
644}
645/// Locale::fmt_percent in the user's format
646pub fn fmt_percent(fraction: f64, decimals: u32) -> String {
647    locale().fmt_percent(fraction, decimals)
648}
649/// Locale::fmt_currency in the user's format
650pub fn fmt_currency(v: f64, code: &str) -> String {
651    locale().fmt_currency(v, code)
652}
653/// Locale::fmt_money in the user's format
654pub fn fmt_money(v: f64) -> String {
655    locale().fmt_money(v)
656}
657/// Locale::fmt_size in the user's format
658pub fn fmt_size(bytes: u64) -> String {
659    locale().fmt_size(bytes)
660}
661/// Locale::fmt_date in the user's format
662pub fn fmt_date(c: &Civil, style: u32) -> String {
663    locale().fmt_date(c, style)
664}
665/// Locale::fmt_time in the user's format
666pub fn fmt_time(c: &Civil) -> String {
667    locale().fmt_time(c)
668}
669/// Locale::fmt_date_time in the user's format
670pub fn fmt_date_time(c: &Civil) -> String {
671    locale().fmt_date_time(c)
672}
673
674// ---------------------------------------------------------------- plural categories
675
676/// a CLDR plural category
677#[derive(Clone, Copy, PartialEq, Eq, Debug)]
678#[non_exhaustive]
679pub enum Plural {
680    Zero,
681    One,
682    Two,
683    Few,
684    Many,
685    Other,
686}
687
688impl Plural {
689    /// the CLDR keyword: "zero", "one", "two", "few", "many", "other"
690    pub fn name(self) -> &'static str {
691        match self {
692            Plural::Zero => "zero",
693            Plural::One => "one",
694            Plural::Two => "two",
695            Plural::Few => "few",
696            Plural::Many => "many",
697            Plural::Other => "other",
698        }
699    }
700}
701
702/// the plural category of an integer in a language ("de", "ru-RU", ...)
703pub fn plural_int(lang: &str, n: i64) -> Plural {
704    plural(lang, n.unsigned_abs(), 0, 0)
705}
706
707/// the plural category of a number: i = its integer digits, v = the number of visible fraction
708/// digits, f = those digits as an integer ("1,50": i 1, v 2, f 50)
709pub fn plural(lang: &str, i: u64, v: u32, f: u64) -> Plural {
710    use Plural::*;
711    let l = lang.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
712    let int = v == 0; // an integer (n = i)
713    let (i10, i100) = (i % 10, i % 100);
714    let (f10, f100) = (f % 10, f % 100);
715    match l.as_str() {
716        "ja" | "zh" | "ko" | "vi" | "th" | "id" | "ms" | "lo" | "km" | "my" | "yue" => Other,
717        "fr" | "pt" => {
718            if i <= 1 { One } else { Other }
719        }
720        "hi" | "bn" | "gu" | "kn" | "mr" | "fa" | "am" | "zu" => {
721            if i == 0 || (i == 1 && f == 0) { One } else { Other }
722        }
723        "ru" | "uk" | "be" => {
724            if !int {
725                Other
726            } else if i10 == 1 && i100 != 11 {
727                One
728            } else if (2..=4).contains(&i10) && !(12..=14).contains(&i100) {
729                Few
730            } else {
731                Many
732            }
733        }
734        "pl" => {
735            if !int {
736                Other
737            } else if i == 1 {
738                One
739            } else if (2..=4).contains(&i10) && !(12..=14).contains(&i100) {
740                Few
741            } else {
742                Many
743            }
744        }
745        "cs" | "sk" => {
746            if !int {
747                Many
748            } else if i == 1 {
749                One
750            } else if (2..=4).contains(&i) {
751                Few
752            } else {
753                Other
754            }
755        }
756        "hr" | "sr" | "bs" | "sh" => {
757            if (int && i10 == 1 && i100 != 11) || (f10 == 1 && f100 != 11) {
758                One
759            } else if (int && (2..=4).contains(&i10) && !(12..=14).contains(&i100)) || ((2..=4).contains(&f10) && !(12..=14).contains(&f100)) {
760                Few
761            } else {
762                Other
763            }
764        }
765        "sl" => {
766            if !int {
767                Few
768            } else if i100 == 1 {
769                One
770            } else if i100 == 2 {
771                Two
772            } else if (3..=4).contains(&i100) {
773                Few
774            } else {
775                Other
776            }
777        }
778        "ro" => {
779            if int && i == 1 {
780                One
781            } else if !int || i == 0 || (1..=19).contains(&i100) {
782                Few
783            } else {
784                Other
785            }
786        }
787        "lt" => {
788            if !int {
789                Many
790            } else if i10 == 1 && !(11..=19).contains(&i100) {
791                One
792            } else if i10 >= 2 && !(11..=19).contains(&i100) {
793                Few
794            } else {
795                Other
796            }
797        }
798        "lv" => {
799            if int && (i10 == 0 || (11..=19).contains(&i100)) {
800                Zero
801            } else if int && i10 == 1 && i100 != 11 {
802                One
803            } else {
804                Other
805            }
806        }
807        "ar" => {
808            if !int {
809                Other
810            } else if i == 0 {
811                Zero
812            } else if i == 1 {
813                One
814            } else if i == 2 {
815                Two
816            } else if (3..=10).contains(&i100) {
817                Few
818            } else if (11..=99).contains(&i100) {
819                Many
820            } else {
821                Other
822            }
823        }
824        "he" | "iw" => {
825            if (int && i == 1) || (i == 0 && !int) {
826                One
827            } else if int && i == 2 {
828                Two
829            } else {
830                Other
831            }
832        }
833        // de, en, nl, sv, da, nb, fi, et, it, es, ca, el, hu, tr, bg, ... : one = 1 without fraction
834        _ => {
835            if int && i == 1 { One } else { Other }
836        }
837    }
838}