Skip to main content

lumen_core/
i18n.rs

1//! Process-wide translation and formatting hooks - the surfaces a script
2//! host reaches an app's locale through.
3//!
4//! Both capabilities live in `lumen-i18n` (Fluent bundles, locale fallback,
5//! ICU4X formatters), which core does not depend on: core carries no
6//! backend crates. What core owns is the seam. The runtime installs a
7//! translator with [`set_translator`] once it has loaded the app's
8//! catalogues, and a formatter with [`set_formatter`] once it knows the
9//! locale; every script host calls [`translate`] from its `t()` / `tr()`
10//! builtin and [`format`] from its `format_*` builtins, without linking
11//! Fluent or ICU or reaching into the world.
12//!
13//! [`format`] takes two opaque strings, a spec and a value, and hands back
14//! what the other side made of them. Core never learns that `currency:EUR`
15//! names a currency, which is what lets a core-owned system apply a format
16//! without core naming the capability.
17//!
18//! This mirrors [`crate::nav`]: one process-global bus, many producers and
19//! consumers, no per-language plumbing. An app that never installs a
20//! translator still resolves every key - [`translate`] returns the key
21//! itself, which is exactly what an untranslated string should render as -
22//! and an app with no formatter leaves every value as it stands.
23//!
24//! An app's locale is not fixed for the life of the process: [`AppI18n`]
25//! carries the switch, and [`active_locale`] answers which locale is
26//! running for a script that has no world to read the resource from.
27//!
28//! One of each is live at a time, so a host process running two Lumen apps
29//! shares the second app's catalogue and locale with the first. Markup
30//! avoids this by reading [`AppI18n`] off the world instead, which is the
31//! same set of handles scoped to one app.
32//!
33//! Both shapes are opaque the whole way through, so a host links only
34//! what it installs. A page whose text was already written for its locale
35//! while the site was built installs nothing here, and carries neither a
36//! catalogue nor a formatter.
37
38use bevy_ecs::resource::Resource;
39use std::sync::{Arc, RwLock};
40
41/// Resolves a translation key against the active catalogue. Returns `None`
42/// when the catalogue has no entry for the key, so callers can apply their
43/// own fallback.
44pub type Translator = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
45
46static TRANSLATOR: RwLock<Option<Translator>> = RwLock::new(None);
47
48/// Install the process-wide translator, replacing any previous one.
49///
50/// The runtime calls this after loading `<app_dir>/locale/*.ftl`. Reloading
51/// a catalogue needs no second call: a translator that closes over shared
52/// state sees the new bundles immediately.
53pub fn set_translator<F>(f: F)
54where
55    F: Fn(&str) -> Option<String> + Send + Sync + 'static,
56{
57    let mut slot = TRANSLATOR.write().unwrap_or_else(|e| e.into_inner());
58    *slot = Some(Arc::new(f));
59}
60
61/// Remove the installed translator. [`translate`] falls back to returning
62/// keys verbatim.
63pub fn clear_translator() {
64    let mut slot = TRANSLATOR.write().unwrap_or_else(|e| e.into_inner());
65    *slot = None;
66}
67
68/// Resolve `key` against the installed translator, or `None` when no
69/// translator is installed or the catalogue lacks the key.
70pub fn try_translate(key: &str) -> Option<String> {
71    let f = {
72        let slot = TRANSLATOR.read().unwrap_or_else(|e| e.into_inner());
73        slot.clone()?
74    };
75    f(key)
76}
77
78/// Resolve `key`, falling back to the key itself. This is what a script's
79/// `t("key")` returns.
80pub fn translate(key: &str) -> String {
81    try_translate(key).unwrap_or_else(|| key.to_string())
82}
83
84/// Formats one value the way one spec asks for. Both arguments are
85/// opaque to core: the spec is whatever a `format` attribute or a
86/// `format_*` builtin wrote, and what it means is the formatter's
87/// business. Returns `None` when the spec is not one it knows or the
88/// value is not what that spec expects, so callers can leave the text
89/// alone.
90pub type Formatter = Arc<dyn Fn(&str, &str) -> Option<String> + Send + Sync>;
91
92static FORMATTER: RwLock<Option<Formatter>> = RwLock::new(None);
93
94/// Install the process-wide formatter, replacing any previous one.
95///
96/// The runtime calls this once it has resolved the app's locale.
97pub fn set_formatter<F>(f: F)
98where
99    F: Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
100{
101    let mut slot = FORMATTER.write().unwrap_or_else(|e| e.into_inner());
102    *slot = Some(Arc::new(f));
103}
104
105/// Remove the installed formatter. [`format`] answers `None` for
106/// everything afterwards.
107pub fn clear_formatter() {
108    let mut slot = FORMATTER.write().unwrap_or_else(|e| e.into_inner());
109    *slot = None;
110}
111
112/// Format `value` per `spec`, or `None` when no formatter is installed,
113/// the spec is not one it knows, or the value is not what the spec
114/// expects. A caller shows `value` unchanged then.
115pub fn format(spec: &str, value: &str) -> Option<String> {
116    let f = {
117        let slot = FORMATTER.read().unwrap_or_else(|e| e.into_inner());
118        slot.clone()?
119    };
120    f(spec, value)
121}
122
123/// The locale the app is running in, as a plain BCP-47 string.
124///
125/// Read by a script's `locale()` builtin, which runs inside a script
126/// engine with no world to reach the per-app handle through. It carries
127/// the same "one live at a time" caveat as the translator hook, and for
128/// the same reason.
129static ACTIVE_LOCALE: RwLock<String> = RwLock::new(String::new());
130
131/// Publish the locale the app is running in. The runtime calls this once
132/// the locale is resolved, and again every time it changes.
133pub fn set_active_locale(tag: &str) {
134    let mut slot = ACTIVE_LOCALE.write().unwrap_or_else(|e| e.into_inner());
135    slot.clear();
136    slot.push_str(tag);
137}
138
139/// The locale the app is running in, or an empty string when nothing has
140/// published one.
141pub fn active_locale() -> String {
142    ACTIVE_LOCALE
143        .read()
144        .unwrap_or_else(|e| e.into_inner())
145        .clone()
146}
147
148/// What changed when an app switched locale: the locale it is now in, and
149/// the writing direction that locale reads in.
150///
151/// Both are things core already owns. The tag is an opaque string, and the
152/// direction is the cascade's own [`crate::components::LayoutDirection`],
153/// so core learns nothing about BCP-47 or Fluent by carrying this back.
154#[derive(Clone, Debug, PartialEq, Eq)]
155pub struct LocaleChange {
156    /// The locale now active.
157    pub locale: String,
158    /// The base writing direction that locale reads in.
159    pub direction: crate::components::LayoutDirection,
160}
161
162/// Switches one app to another locale, answering with what changed, or
163/// `None` when the tag is not a locale at all and nothing moved.
164///
165/// Naming a locale the app has no catalogue for is not a failure: every
166/// message falls back, which is the rule startup already follows.
167pub type LocaleSetter = Arc<dyn Fn(&str) -> Option<LocaleChange> + Send + Sync>;
168
169/// One app's own translator, formatter and locale switch, held in its
170/// world.
171///
172/// The hooks above are process-wide, which is what a script host needs
173/// and all it can reach. Markup is spawned from a world, so it reads
174/// this instead: a process hosting two Lumen apps renders each app's
175/// text in its own locale, and switching one app's locale leaves the
176/// other where it was.
177///
178/// The handles are the same opaque ones. Core forwards a key, or a spec
179/// and a value, or a locale tag, and returns what came back; the locale
180/// side of the seam is what the runtime installs here.
181#[derive(Resource, Clone)]
182pub struct AppI18n {
183    translator: Translator,
184    formatter: Formatter,
185    set_locale: LocaleSetter,
186}
187
188impl AppI18n {
189    /// Pair a translator, a formatter and a locale switch for one app.
190    pub fn new(translator: Translator, formatter: Formatter, set_locale: LocaleSetter) -> Self {
191        Self {
192            translator,
193            formatter,
194            set_locale,
195        }
196    }
197
198    /// Resolve `key` against this app's catalogue, or `None` on a miss.
199    pub fn try_translate(&self, key: &str) -> Option<String> {
200        (self.translator)(key)
201    }
202
203    /// Format `value` per `spec` for this app's locale, or `None` when
204    /// the spec is not one it knows or the value is not what that spec
205    /// expects. A caller shows `value` unchanged then.
206    pub fn format(&self, spec: &str, value: &str) -> Option<String> {
207        (self.formatter)(spec, value)
208    }
209
210    /// Switch this app to `tag`, answering with the locale it is now in
211    /// and the writing direction to read it in. `None` means `tag` is
212    /// not a locale tag and nothing changed.
213    ///
214    /// Reach this through a `ResMut<AppI18n>` borrow: the systems that
215    /// rebuild an app's text wake on the resource being marked changed.
216    pub fn set_locale(&self, tag: &str) -> Option<LocaleChange> {
217        (self.set_locale)(tag)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    // The translator and formatter slots are process-global, so these
226    // run one at a time.
227    static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
228
229    #[test]
230    fn missing_translator_returns_key() {
231        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
232        clear_translator();
233        assert_eq!(translate("app-title"), "app-title");
234        assert_eq!(try_translate("app-title"), None);
235    }
236
237    #[test]
238    fn installed_translator_resolves_and_falls_back() {
239        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
240        set_translator(|key| (key == "greet").then(|| "Hallo".to_string()));
241        assert_eq!(translate("greet"), "Hallo");
242        assert_eq!(translate("nope"), "nope");
243        clear_translator();
244    }
245
246    #[test]
247    fn set_replaces_previous() {
248        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
249        set_translator(|_| Some("first".to_string()));
250        set_translator(|_| Some("second".to_string()));
251        assert_eq!(translate("any"), "second");
252        clear_translator();
253    }
254
255    #[test]
256    fn missing_formatter_answers_nothing() {
257        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
258        clear_formatter();
259        assert_eq!(format("number", "1234.5"), None);
260    }
261
262    #[test]
263    fn installed_formatter_sees_the_spec_and_the_value() {
264        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
265        set_formatter(|spec, value| (spec == "number").then(|| format!("<{value}>")));
266        assert_eq!(format("number", "1234.5").as_deref(), Some("<1234.5>"));
267        assert_eq!(format("wat", "1234.5"), None);
268        clear_formatter();
269    }
270
271    #[test]
272    fn app_handles_answer_without_the_process_hooks() {
273        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
274        clear_translator();
275        clear_formatter();
276        let app = AppI18n::new(
277            Arc::new(|key| (key == "greet").then(|| "Hallo".to_string())),
278            Arc::new(|spec, value| (spec == "number").then(|| format!("<{value}>"))),
279            Arc::new(|_| None),
280        );
281        assert_eq!(app.try_translate("greet").as_deref(), Some("Hallo"));
282        assert_eq!(app.try_translate("nope"), None);
283        assert_eq!(app.format("number", "1234.5").as_deref(), Some("<1234.5>"));
284        assert_eq!(app.format("wat", "1234.5"), None);
285    }
286
287    #[test]
288    fn the_locale_switch_answers_with_what_changed() {
289        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
290        let app = AppI18n::new(
291            Arc::new(|_| None),
292            Arc::new(|_, _| None),
293            Arc::new(|tag| {
294                (tag == "ar-EG").then(|| LocaleChange {
295                    locale: tag.to_string(),
296                    direction: crate::components::LayoutDirection::Rtl,
297                })
298            }),
299        );
300        let change = app.set_locale("ar-EG").expect("a locale the switch takes");
301        assert_eq!(change.locale, "ar-EG");
302        assert_eq!(change.direction, crate::components::LayoutDirection::Rtl);
303        assert_eq!(app.set_locale("not a tag"), None);
304    }
305
306    #[test]
307    fn the_active_locale_starts_empty_and_holds_what_was_published() {
308        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
309        set_active_locale("");
310        assert_eq!(active_locale(), "");
311        set_active_locale("de-DE");
312        assert_eq!(active_locale(), "de-DE");
313        set_active_locale("");
314    }
315
316    #[test]
317    fn set_formatter_replaces_previous() {
318        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
319        set_formatter(|_, _| Some("first".to_string()));
320        set_formatter(|_, _| Some("second".to_string()));
321        assert_eq!(format("number", "1").as_deref(), Some("second"));
322        clear_formatter();
323    }
324}