1use 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#[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#[derive(Clone, PartialEq, Eq, Debug, Default)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub enum Language {
44 #[default]
46 System,
47 Explicit(String),
50}
51
52#[derive(Clone, Copy, Debug)]
55pub struct Resources {
56 pub name: &'static str,
58 pub files: &'static [(&'static str, &'static str)],
60}
61
62pub 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#[derive(Clone)]
75struct Registered(Vec<Resources>);
76
77#[derive(Clone)]
79struct Installed {
80 settings: Option<LanguageSettings>,
82 chosen: LanguageIdentifier,
83 system: Vec<String>,
85 bundle: Arc<Bundle>,
86 english: Arc<Bundle>,
87 reported: Arc<Mutex<HashSet<String>>>,
89 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
105fn 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
111pub 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 Some(_) => ctx.data_mut(|d| d.remove::<Installed>(installed_key())),
128 None => {}
129 }
130}
131
132pub fn install(ctx: &egui::Context, settings: &LanguageSettings) {
135 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
146pub 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
152pub 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
158pub(crate) fn installed_system_languages(ctx: &egui::Context) -> Vec<String> {
161 state(ctx).system
162}
163
164pub 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
177pub fn tr(ctx: &egui::Context, id: &str) -> String {
179 tr_args(ctx, id, &[])
180}
181
182pub 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
199pub 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(®istered(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
222pub trait AsContext {
225 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#[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
271pub(crate) fn choose(requested: &[String], available: &[LanguageIdentifier]) -> LanguageIdentifier {
274 use fluent_langneg::{LanguageIdentifier as Tag, NegotiationStrategy, negotiate_languages};
275 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
297fn 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
331fn bundle_for(resources: &[Resources], lang: &LanguageIdentifier) -> Bundle {
334 let mut bundle = Bundle::new_concurrent(vec![lang.clone()]);
335 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
368fn 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
388fn 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 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}