Skip to main content

corundum_framework_gui/i18n/
mod.rs

1//! The translations: every string the user reads, looked up by id in the language the user
2//! chose.
3//!
4//! The strings are [Fluent](https://projectfluent.org) messages, in one `.ftl` file per
5//! language per crate, embedded as [`Resources`]. corundum-framework-gui's own ([`CORUNDUM`]) are
6//! always there; an app adds its own with [`register`], and a key in them replaces the
7//! same key from those registered before.
8//!
9//! [`install`] chooses the language from [`LanguageSettings`] and keeps it in the egui
10//! context, as [`theme::install`](crate::theme::install) keeps the theme. [`tr`] and
11//! [`tr_args`] — or the [`tr!`](crate::tr!) macro — look a message up in it, then in English,
12//! then give the id itself. A miss is logged once per id.
13//!
14//! Text decided outside egui, in plain functions, is a [`Message`]: an id and its
15//! arguments, resolved by the view that draws it.
16
17use std::collections::{HashMap, HashSet};
18use std::sync::{Arc, Mutex};
19
20use fluent_bundle::concurrent::FluentBundle;
21use fluent_bundle::{FluentArgs, FluentResource};
22pub use unic_langid::LanguageIdentifier;
23
24pub mod check;
25mod message;
26mod ui;
27
28pub use corundum_framework_platform::locale::system_languages;
29pub use message::{Arg, Message};
30pub use ui::settings_ui;
31
32/// What the user chose: what the app saves, and hands to [`install`] at start.
33#[derive(Clone, PartialEq, Eq, Debug, Default)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35#[cfg_attr(feature = "serde", serde(default))]
36pub struct LanguageSettings {
37    pub language: Language,
38}
39
40/// Which language to show.
41#[derive(Clone, PartialEq, Eq, Debug, Default)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub enum Language {
44    /// The system's preferred languages, in order.
45    #[default]
46    System,
47    /// A BCP 47 tag: `"ja"`, `"en-GB"`. One no file has, or that does not parse, gives
48    /// English.
49    Explicit(String),
50}
51
52/// One crate's translations: `(language tag, .ftl source)` pairs, embedded with
53/// `include_str!`.
54#[derive(Clone, Copy, Debug)]
55pub struct Resources {
56    /// Names the set: [`register`] adds a name once.
57    pub name: &'static str,
58    /// `(language tag, .ftl source)` pairs, one per language.
59    pub files: &'static [(&'static str, &'static str)],
60}
61
62/// corundum-framework-gui's own; always registered first.
63pub const CORUNDUM: Resources = Resources {
64    name: "corundum-framework-gui",
65    files: &[
66        ("en", include_str!("../../locales/en/corundum.ftl")),
67        ("ja", include_str!("../../locales/ja/corundum.ftl")),
68    ],
69};
70
71type Bundle = FluentBundle<FluentResource>;
72
73/// The registered [`Resources`], in order; [`CORUNDUM`] first.
74#[derive(Clone)]
75struct Registered(Vec<Resources>);
76
77/// What [`install`] built, or — before it — English alone, built on the first lookup.
78#[derive(Clone)]
79struct Installed {
80    /// `None` before [`install`].
81    settings: Option<LanguageSettings>,
82    chosen: LanguageIdentifier,
83    /// The system's languages as [`system_languages`] gave them when this was built.
84    system: Vec<String>,
85    bundle: Arc<Bundle>,
86    english: Arc<Bundle>,
87    /// The ids already logged as missing or failing to format.
88    reported: Arc<Mutex<HashSet<String>>>,
89    /// Each language's name in itself, for [`name_of`], filled as asked for.
90    names: Arc<Mutex<HashMap<LanguageIdentifier, String>>>,
91}
92
93fn registered_key() -> egui::Id {
94    egui::Id::unique("corundum_i18n_resources")
95}
96
97fn installed_key() -> egui::Id {
98    egui::Id::unique("corundum_i18n")
99}
100
101fn english() -> LanguageIdentifier {
102    LanguageIdentifier::from_bytes(b"en").expect("\"en\" is a language tag")
103}
104
105/// The registered resources, [`CORUNDUM`] first whether or not anything was registered.
106fn registered(ctx: &egui::Context) -> Vec<Resources> {
107    ctx.data(|d| d.get_temp::<Registered>(registered_key()))
108        .map_or_else(|| vec![CORUNDUM], |r| r.0)
109}
110
111/// Add `resources` after those registered before; a key in them replaces the same key
112/// from earlier ones. A name already registered is ignored, so calling this every frame
113/// is cheap. If a language is installed, it is rebuilt with them.
114pub fn register(ctx: &egui::Context, resources: Resources) {
115    let mut list = registered(ctx);
116    if list.iter().any(|r| r.name == resources.name) {
117        return;
118    }
119    list.push(resources);
120    ctx.data_mut(|d| d.insert_temp(registered_key(), Registered(list)));
121    match ctx.data(|d| d.get_temp::<Installed>(installed_key())) {
122        Some(Installed {
123            settings: Some(settings),
124            ..
125        }) => install(ctx, &settings),
126        // English alone, built for a lookup before `install`: built again on the next.
127        Some(_) => ctx.data_mut(|d| d.remove::<Installed>(installed_key())),
128        None => {}
129    }
130}
131
132/// Use `settings` in `ctx`: choose the language, build its messages and English's, and
133/// store both. Call it at start and whenever the settings change.
134pub fn install(ctx: &egui::Context, settings: &LanguageSettings) {
135    // Read once here, and kept for the language picker, so it need not ask each frame.
136    let system = system_languages();
137    let requested = match &settings.language {
138        Language::System => system.clone(),
139        Language::Explicit(tag) => vec![tag.clone()],
140    };
141    let chosen = choose(&requested, &available(ctx));
142    let installed = build(ctx, Some(settings.clone()), chosen, system);
143    ctx.data_mut(|d| d.insert_temp(installed_key(), installed));
144}
145
146/// Whether [`install`] has been called on `ctx`.
147pub fn is_installed(ctx: &egui::Context) -> bool {
148    ctx.data(|d| d.get_temp::<Installed>(installed_key()))
149        .is_some_and(|i| i.settings.is_some())
150}
151
152/// The language chosen by the last [`install`]: English before any.
153pub fn current(ctx: &egui::Context) -> LanguageIdentifier {
154    ctx.data(|d| d.get_temp::<Installed>(installed_key()))
155        .map_or_else(english, |i| i.chosen)
156}
157
158/// The system's languages as read by the last [`install`], so a caller drawing every frame
159/// does not ask the platform each time.
160pub(crate) fn installed_system_languages(ctx: &egui::Context) -> Vec<String> {
161    state(ctx).system
162}
163
164/// The languages the registered resources have: English first, then the rest by tag.
165pub fn available(ctx: &egui::Context) -> Vec<LanguageIdentifier> {
166    let mut langs: Vec<LanguageIdentifier> = registered(ctx)
167        .iter()
168        .flat_map(|r| r.files.iter())
169        .filter_map(|(tag, _)| tag.parse().ok())
170        .collect();
171    let en = english();
172    langs.sort_by_key(|l| (*l != en, l.to_string()));
173    langs.dedup();
174    langs
175}
176
177/// The message `id`, in the installed language. See [`tr_args`].
178pub fn tr(ctx: &egui::Context, id: &str) -> String {
179    tr_args(ctx, id, &[])
180}
181
182/// The message `id` with `args`, in the installed language; else in English; else `id`
183/// itself. A miss, or an error formatting it (a missing argument, say), is logged once per
184/// id; the text formatted despite the error is still given.
185pub fn tr_args(ctx: &egui::Context, id: &str, args: &[(&str, Arg)]) -> String {
186    let installed = state(ctx);
187    let args = fluent_args(args);
188    for bundle in [&installed.bundle, &installed.english] {
189        if let Some(text) = format(&installed, bundle, id, args.as_ref()) {
190            return text;
191        }
192    }
193    report(&installed, id, || {
194        tracing::warn!(id, "no message with this id in any language");
195    });
196    id.to_owned()
197}
198
199/// `lang`'s name in itself — `corundum-language-name` from its own resources ("English",
200/// "日本語") — for a language picker. The tag, if it has none.
201pub fn name_of(ctx: &egui::Context, lang: &LanguageIdentifier) -> String {
202    let installed = state(ctx);
203    let mut names = installed.names.lock().unwrap_or_else(|e| e.into_inner());
204    names
205        .entry(lang.clone())
206        .or_insert_with(|| {
207            let bundle = bundle_for(&registered(ctx), lang);
208            bundle
209                .get_message("corundum-language-name")
210                .and_then(|m| m.value())
211                .map(|pattern| {
212                    let mut errors = Vec::new();
213                    bundle
214                        .format_pattern(pattern, None, &mut errors)
215                        .into_owned()
216                })
217                .unwrap_or_else(|| lang.to_string())
218        })
219        .clone()
220}
221
222/// Anything [`tr!`](crate::tr!) can look strings up through: an [`egui::Ui`] or an
223/// [`egui::Context`].
224pub trait AsContext {
225    /// The egui context to look strings up in.
226    fn as_context(&self) -> &egui::Context;
227}
228
229impl AsContext for egui::Context {
230    fn as_context(&self) -> &egui::Context {
231        self
232    }
233}
234
235impl AsContext for egui::Ui {
236    fn as_context(&self) -> &egui::Context {
237        self.ctx()
238    }
239}
240
241/// Look a message up: [`tr`](crate::i18n::tr) or, with arguments,
242/// [`tr_args`](crate::i18n::tr_args).
243///
244/// The first argument is anything with a context ([`AsContext`](crate::i18n::AsContext)):
245/// a `Ui` or a `Context`. Each argument after the id is `name = value`, the value anything
246/// that converts into an [`Arg`](crate::i18n::Arg).
247///
248/// ```ignore
249/// tr!(ui, "corundum-language")
250/// tr!(ui, "sapphire-version", version = app.version)
251/// ```
252#[macro_export]
253macro_rules! tr {
254    ($c:expr, $id:expr $(,)?) => {{
255        use $crate::i18n::AsContext as _;
256        $crate::i18n::tr(($c).as_context(), $id)
257    }};
258    ($c:expr, $id:expr, $($name:ident = $value:expr),+ $(,)?) => {{
259        use $crate::i18n::AsContext as _;
260        $crate::i18n::tr_args(
261            ($c).as_context(),
262            $id,
263            &[$((
264                ::core::stringify!($name),
265                ::core::convert::Into::<$crate::i18n::Arg>::into($value),
266            )),+],
267        )
268    }};
269}
270
271/// The best of `available` for `requested`, English when none matches. A tag that does not
272/// parse matches nothing.
273pub(crate) fn choose(requested: &[String], available: &[LanguageIdentifier]) -> LanguageIdentifier {
274    use fluent_langneg::{LanguageIdentifier as Tag, NegotiationStrategy, negotiate_languages};
275    // fluent-langneg has its own identifier type; the two meet as tags.
276    let requested: Vec<Tag> = requested
277        .iter()
278        .filter(|t| !t.is_empty())
279        .filter_map(|t| t.parse().ok())
280        .collect();
281    let offered: Vec<Tag> = available
282        .iter()
283        .filter_map(|l| l.to_string().parse().ok())
284        .collect();
285    let default: Tag = "en".parse().expect("\"en\" is a language tag");
286    negotiate_languages(
287        &requested,
288        &offered,
289        Some(&default),
290        NegotiationStrategy::Filtering,
291    )
292    .first()
293    .and_then(|tag| tag.to_string().parse().ok())
294    .unwrap_or_else(english)
295}
296
297/// What [`install`] left, or English alone (stored for the next lookup) before it.
298fn state(ctx: &egui::Context) -> Installed {
299    if let Some(installed) = ctx.data(|d| d.get_temp::<Installed>(installed_key())) {
300        return installed;
301    }
302    let installed = build(ctx, None, english(), system_languages());
303    ctx.data_mut(|d| d.insert_temp(installed_key(), installed.clone()));
304    installed
305}
306
307fn build(
308    ctx: &egui::Context,
309    settings: Option<LanguageSettings>,
310    chosen: LanguageIdentifier,
311    system: Vec<String>,
312) -> Installed {
313    let resources = registered(ctx);
314    let english = Arc::new(bundle_for(&resources, &english()));
315    let bundle = if chosen == self::english() {
316        english.clone()
317    } else {
318        Arc::new(bundle_for(&resources, &chosen))
319    };
320    Installed {
321        settings,
322        chosen,
323        system,
324        bundle,
325        english,
326        reported: Arc::default(),
327        names: Arc::default(),
328    }
329}
330
331/// `lang`'s messages from every resource that has them, later ones replacing earlier ones'.
332/// A file that does not parse is logged and its parsed part used.
333fn bundle_for(resources: &[Resources], lang: &LanguageIdentifier) -> Bundle {
334    let mut bundle = Bundle::new_concurrent(vec![lang.clone()]);
335    // Fluent wraps each argument in bidi isolation marks, which egui draws as boxes.
336    bundle.set_use_isolating(false);
337    for r in resources {
338        for (tag, source) in r.files {
339            if tag.parse::<LanguageIdentifier>().ok().as_ref() != Some(lang) {
340                continue;
341            }
342            let resource = match FluentResource::try_new(source.to_string()) {
343                Ok(resource) => resource,
344                Err((resource, errors)) => {
345                    for error in errors {
346                        tracing::warn!(resources = r.name, tag, %error, "translation file error");
347                    }
348                    resource
349                }
350            };
351            bundle.add_resource_overriding(resource);
352        }
353    }
354    bundle
355}
356
357fn fluent_args<'a>(args: &'a [(&'a str, Arg)]) -> Option<FluentArgs<'a>> {
358    if args.is_empty() {
359        return None;
360    }
361    let mut fluent = FluentArgs::with_capacity(args.len());
362    for (name, value) in args {
363        fluent.set(*name, value.to_fluent());
364    }
365    Some(fluent)
366}
367
368/// `id` from `bundle`, or `None` if it has no such message (or one without a value).
369fn format(
370    installed: &Installed,
371    bundle: &Bundle,
372    id: &str,
373    args: Option<&FluentArgs>,
374) -> Option<String> {
375    let pattern = bundle.get_message(id)?.value()?;
376    let mut errors = Vec::new();
377    let text = bundle
378        .format_pattern(pattern, args, &mut errors)
379        .into_owned();
380    if !errors.is_empty() {
381        report(installed, id, || {
382            tracing::warn!(id, ?errors, "error formatting a message");
383        });
384    }
385    Some(text)
386}
387
388/// Run `log` the first time `id` is reported.
389fn report(installed: &Installed, id: &str, log: impl FnOnce()) {
390    let mut reported = installed.reported.lock().unwrap_or_else(|e| e.into_inner());
391    if reported.insert(id.to_owned()) {
392        log();
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn lang(tag: &str) -> LanguageIdentifier {
401        tag.parse().unwrap()
402    }
403
404    fn explicit(tag: &str) -> LanguageSettings {
405        LanguageSettings {
406            language: Language::Explicit(tag.to_owned()),
407        }
408    }
409
410    const TEST: Resources = Resources {
411        name: "test",
412        files: &[
413            (
414                "en",
415                "only-en = Only English\n\
416                 greet = Hello { $name }!\n\
417                 peers = { $n ->\n    [one] one peer\n   *[other] { $n } peers\n}\n",
418            ),
419            ("ja", "greet = こんにちは { $name }!\n"),
420        ],
421    };
422
423    #[test]
424    fn choose_matches_regions_and_falls_back() {
425        let available = [lang("en"), lang("ja")];
426        let pick = |tags: &[&str]| {
427            let tags: Vec<String> = tags.iter().map(|t| t.to_string()).collect();
428            choose(&tags, &available)
429        };
430        assert_eq!(pick(&["ja-JP", "en-US"]), lang("ja"));
431        assert_eq!(pick(&["en-US"]), lang("en"));
432        assert_eq!(pick(&["fr"]), lang("en"));
433        assert_eq!(pick(&["zh-Hant-TW"]), lang("en"));
434        assert_eq!(pick(&[]), lang("en"));
435        assert_eq!(pick(&["xx-!!", ""]), lang("en"));
436    }
437
438    #[test]
439    fn explicit_and_garbage_tags() {
440        let ctx = egui::Context::default();
441        install(&ctx, &explicit("ja"));
442        assert_eq!(current(&ctx), lang("ja"));
443        assert_eq!(tr(&ctx, "corundum-language"), "言語");
444        install(&ctx, &explicit("xx-!!"));
445        assert_eq!(current(&ctx), lang("en"));
446        assert_eq!(tr(&ctx, "corundum-language"), "Language");
447        install(&ctx, &explicit(""));
448        assert_eq!(current(&ctx), lang("en"));
449    }
450
451    #[test]
452    fn falls_back_to_english_then_the_id() {
453        let ctx = egui::Context::default();
454        register(&ctx, TEST);
455        install(&ctx, &explicit("ja"));
456        assert_eq!(tr(&ctx, "only-en"), "Only English");
457        assert_eq!(tr(&ctx, "missing-id"), "missing-id");
458        // Reported once, and still the id the second time.
459        assert_eq!(tr(&ctx, "missing-id"), "missing-id");
460    }
461
462    #[test]
463    fn missing_argument_still_gives_text() {
464        let ctx = egui::Context::default();
465        register(&ctx, TEST);
466        install(&ctx, &explicit("en"));
467        assert!(tr(&ctx, "greet").contains("Hello"));
468        install(&ctx, &explicit("ja"));
469        assert!(tr(&ctx, "greet").contains("こんにちは"));
470    }
471
472    #[test]
473    fn plural_follows_the_number() {
474        let ctx = egui::Context::default();
475        register(&ctx, TEST);
476        install(&ctx, &explicit("en"));
477        assert_eq!(tr_args(&ctx, "peers", &[("n", Arg::from(1))]), "one peer");
478        let two = tr_args(&ctx, "peers", &[("n", Arg::from(2))]);
479        assert_eq!(two, "2 peers");
480        assert!(!two.contains('\u{2068}'));
481        assert_eq!(crate::tr!(ctx, "peers", n = 2u64), "2 peers");
482    }
483
484    #[test]
485    fn later_registration_overrides_and_rebuilds() {
486        let ctx = egui::Context::default();
487        install(&ctx, &explicit("en"));
488        assert_eq!(tr(&ctx, "corundum-language"), "Language");
489        register(
490            &ctx,
491            Resources {
492                name: "X",
493                files: &[("en", "corundum-language = Tongue\n")],
494            },
495        );
496        assert_eq!(tr(&ctx, "corundum-language"), "Tongue");
497    }
498
499    #[test]
500    fn register_is_idempotent_by_name() {
501        let ctx = egui::Context::default();
502        register(&ctx, TEST);
503        register(&ctx, TEST);
504        let names: Vec<_> = registered(&ctx).iter().map(|r| r.name).collect();
505        assert_eq!(names, ["corundum-framework-gui", "test"]);
506    }
507
508    #[test]
509    fn switching_takes_effect_on_the_next_lookup() {
510        let ctx = egui::Context::default();
511        install(&ctx, &explicit("ja"));
512        assert_eq!(tr(&ctx, "corundum-language"), "言語");
513        install(&ctx, &explicit("en"));
514        assert_eq!(tr(&ctx, "corundum-language"), "Language");
515    }
516
517    #[test]
518    fn message_text_resolves_args() {
519        let ctx = egui::Context::default();
520        register(&ctx, TEST);
521        install(&ctx, &explicit("en"));
522        assert_eq!(Message::new("peers").arg("n", 2).text(&ctx), "2 peers");
523    }
524
525    #[test]
526    fn available_lists_english_first() {
527        let ctx = egui::Context::default();
528        assert_eq!(available(&ctx), [lang("en"), lang("ja")]);
529    }
530
531    #[test]
532    fn before_install_english_is_used() {
533        let ctx = egui::Context::default();
534        assert!(!is_installed(&ctx));
535        assert_eq!(current(&ctx), lang("en"));
536        assert_eq!(tr(&ctx, "corundum-language"), "Language");
537        assert!(!is_installed(&ctx));
538        install(&ctx, &LanguageSettings::default());
539        assert!(is_installed(&ctx));
540    }
541
542    #[test]
543    fn name_of_uses_the_languages_own_name() {
544        let ctx = egui::Context::default();
545        install(&ctx, &explicit("en"));
546        assert_eq!(name_of(&ctx, &lang("ja")), "日本語");
547        assert_eq!(name_of(&ctx, &lang("en")), "English");
548    }
549
550    #[test]
551    fn tr_takes_a_ui() {
552        let ctx = egui::Context::default();
553        install(&ctx, &explicit("ja"));
554        let mut text = String::new();
555        crate::test_support::render_in(&ctx, [200.0, 100.0], |ui| {
556            text = crate::tr!(ui, "corundum-language-system", language = "日本語");
557        });
558        assert_eq!(text, "システム(日本語)");
559    }
560
561    #[cfg(feature = "serde")]
562    #[test]
563    fn settings_round_trip() {
564        for settings in [explicit("ja"), LanguageSettings::default()] {
565            let json = serde_json::to_string(&settings).unwrap();
566            assert_eq!(
567                serde_json::from_str::<LanguageSettings>(&json).unwrap(),
568                settings
569            );
570        }
571    }
572}