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}