Skip to main content

mobiler_core/
i18n.rs

1//! Tiny, dependency-free localization: pick a UI language from the device, then translate keys.
2//!
3//! `view()` is synchronous, so translations can't be fetched from the platform at render time — they
4//! live in the core as a small in-memory [`Catalog`] the app builds from string literals (typically
5//! once, behind a `OnceLock`) and reads while rendering. The framework supplies the machinery
6//! ([`negotiate`] + [`Catalog`]); the app supplies its own languages and strings. Pair it with
7//! [`Cx::device_locale`](crate::Cx::device_locale) at startup and the
8//! [`format`](crate::format) module for numbers/dates.
9//!
10//! ```
11//! use mobiler_core::i18n::{negotiate, Catalog};
12//!
13//! // Choose the UI language from the device tag, against the languages the app ships.
14//! let lang = negotiate("de-CH", &["en", "de", "fr", "it", "uk"], "en");
15//! assert_eq!(lang, "de");
16//!
17//! let cat = Catalog::new("en")
18//!     .with("save", &[("en", "Save"), ("de", "Speichern"), ("uk", "Зберегти")]);
19//! assert_eq!(cat.tr("save", &lang), "Speichern");
20//! assert_eq!(cat.tr("save", "fr"), "Save"); // missing language → the default language
21//! assert_eq!(cat.tr("undefined", "de"), "undefined"); // missing key → the key itself
22//! ```
23
24use std::collections::BTreeMap;
25
26/// Pick the best-matching language **code** from `supported` for a device BCP-47 `tag`, comparing on
27/// the language subtag only (case-insensitive); falls back to `default`. Returns an owned code so it
28/// can be stored in the model and handed to [`Catalog::tr`].
29///
30/// ```
31/// use mobiler_core::i18n::negotiate;
32/// assert_eq!(negotiate("uk-UA", &["en", "uk"], "en"), "uk");
33/// assert_eq!(negotiate("EN", &["en", "de"], "en"), "en"); // case-insensitive
34/// assert_eq!(negotiate("ja-JP", &["en", "de"], "en"), "en"); // unsupported → default
35/// assert_eq!(negotiate("", &["en"], "en"), "en");
36/// ```
37#[must_use]
38pub fn negotiate(tag: &str, supported: &[&str], default: &str) -> String {
39    let lang = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
40    supported
41        .iter()
42        .find(|s| s.eq_ignore_ascii_case(&lang))
43        .map_or_else(|| default.to_string(), |s| (*s).to_string())
44}
45
46/// An app-populated translation table: `key → (language code → string)`, with a default language used
47/// as a fallback. Build it once from string literals (e.g. behind a `OnceLock`) and read it in `view`.
48///
49/// Lookups fall back in two steps — the requested language, then the default language, then the key
50/// itself — so a missing translation degrades to *something* readable rather than blank.
51#[derive(Clone, Debug, Default)]
52pub struct Catalog {
53    default: String,
54    entries: BTreeMap<&'static str, BTreeMap<&'static str, &'static str>>,
55}
56
57impl Catalog {
58    /// A new, empty catalog whose `default_lang` is the fallback when a requested language is missing.
59    #[must_use]
60    pub fn new(default_lang: &str) -> Self {
61        Self { default: default_lang.to_string(), entries: BTreeMap::new() }
62    }
63
64    /// Add (or extend) the translations for `key`. Chainable, for building the catalog inline.
65    ///
66    /// ```
67    /// use mobiler_core::i18n::Catalog;
68    /// let c = Catalog::new("en")
69    ///     .with("hello", &[("en", "Hello"), ("uk", "Привіт")])
70    ///     .with("bye", &[("en", "Bye"), ("uk", "Бувай")]);
71    /// assert_eq!(c.tr("hello", "uk"), "Привіт");
72    /// ```
73    #[must_use]
74    pub fn with(mut self, key: &'static str, langs: &[(&'static str, &'static str)]) -> Self {
75        self.entries.entry(key).or_default().extend(langs.iter().copied());
76        self
77    }
78
79    /// Translate `key` into `lang`, falling back to the default language, then to `key` itself.
80    #[must_use]
81    pub fn tr<'a>(&'a self, key: &'a str, lang: &str) -> &'a str {
82        let Some(by_lang) = self.entries.get(key) else { return key };
83        by_lang.get(lang).or_else(|| by_lang.get(self.default.as_str())).copied().unwrap_or(key)
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    const SUPPORTED: [&str; 5] = ["en", "de", "fr", "it", "uk"];
92
93    #[test]
94    fn negotiate_matches_language_subtag() {
95        assert_eq!(negotiate("de-CH", &SUPPORTED, "en"), "de");
96        assert_eq!(negotiate("uk_UA", &SUPPORTED, "en"), "uk"); // underscore form
97        assert_eq!(negotiate("FR", &SUPPORTED, "en"), "fr"); // case-insensitive, no region
98        assert_eq!(negotiate("pt-BR", &SUPPORTED, "en"), "en"); // unsupported → default
99        assert_eq!(negotiate("", &SUPPORTED, "en"), "en");
100    }
101
102    #[test]
103    fn tr_falls_back_language_then_key() {
104        let cat = Catalog::new("en")
105            .with("ok", &[("en", "OK"), ("de", "OK"), ("uk", "Гаразд")])
106            .with("save", &[("en", "Save"), ("uk", "Зберегти")]);
107        assert_eq!(cat.tr("save", "uk"), "Зберегти"); // exact
108        assert_eq!(cat.tr("save", "de"), "Save"); // missing language → default language
109        assert_eq!(cat.tr("save", ""), "Save"); // empty language → default language
110        assert_eq!(cat.tr("missing", "uk"), "missing"); // missing key → key itself
111        assert_eq!(cat.tr("ok", "uk"), "Гаразд");
112    }
113
114    #[test]
115    fn with_extends_an_existing_key() {
116        let cat = Catalog::new("en").with("x", &[("en", "X")]).with("x", &[("de", "X-de")]);
117        assert_eq!(cat.tr("x", "en"), "X");
118        assert_eq!(cat.tr("x", "de"), "X-de");
119    }
120}