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}