Files
solidcraft-upstream/crates/ui-egui/src/i18n.rs
T
db6ecaf446 Add Korean translation (#234)
* Create ko.json

* Update ko.json

* Update i18n.rs

* Fonts: add a Hangul fallback so Korean doesn't render as boxes

Only the first CJK font found was loaded, and on Windows and macOS that is
a Japanese font without Hangul (Meiryo, Hiragino). Load the first readable
font of each group: the existing CJK list, then Malgun Gothic (Windows),
Apple SD Gothic Neo (macOS) or Nanum Gothic (Linux). Also rustfmt the
Korean catalog test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:06:03 -04:00

290 lines
12 KiB
Rust

//! Embedded offline message catalogs. UI messages use stable IDs; engine display labels
//! pass through an adapter without changing engine or scenario identifiers.
use std::cell::Cell;
use std::collections::BTreeMap;
use std::sync::OnceLock;
type Messages = BTreeMap<String, String>;
/// Register locales here after adding their catalogs. Missing translations use English.
pub struct Catalog {
pub id: &'static str,
pub name: &'static str,
source: &'static str,
messages: OnceLock<Messages>,
}
impl Catalog {
const fn new(id: &'static str, name: &'static str, source: &'static str) -> Self {
Self { id, name, source, messages: OnceLock::new() }
}
fn messages(&'static self) -> &'static Messages {
self.messages.get_or_init(|| match serde_json::from_str(self.source) {
Ok(messages) => messages,
Err(error) => {
log::error!("invalid embedded {} message catalog: {error}", self.id);
Messages::new()
}
})
}
}
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!("locales/de.json"));
static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!("locales/ko.json"));
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE, &GERMAN, &KOREAN];
pub fn is_supported(id: &str) -> bool {
LOCALES.iter().any(|catalog| catalog.id == id)
}
/// Immutable locale reference. Catalogs parse once; lookups are logarithmic.
#[derive(Clone, Copy)]
pub struct Locale(&'static Catalog);
impl Locale {
pub fn for_language(id: &str) -> Self {
Self(LOCALES.iter().copied().find(|catalog| catalog.id == id).unwrap_or(&ENGLISH))
}
pub fn name(self) -> &'static str {
self.0.name
}
/// Stable message ID → translation → English → ID.
pub fn text(self, id: &str) -> &str {
self.0.messages().get(id).or_else(|| ENGLISH.messages().get(id)).map_or(id, String::as_str)
}
/// Translate source labels from engine/UI tables, preserving unknown labels.
/// Also accepts stable IDs while table helpers migrate away from source text.
pub fn label(self, label: &str) -> &str {
static LABEL_IDS: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();
let ids = LABEL_IDS.get_or_init(|| {
ENGLISH.messages().iter().filter(|(_, text)| !text.contains('{')).map(|(id, text)| (text.as_str(), id.as_str())).collect()
});
ids.get(label).map_or_else(|| self.text(label), |id| self.text(id))
}
pub fn format(self, id: &str, args: &[(&str, &str)]) -> String {
interpolate(self.text(id), args)
}
/// Translate generated browser names and labels, preserving custom names otherwise.
pub fn dynamic(self, text: &str) -> String {
for (prefix, id) in [("Sketch", "browser.sketch_name"), ("Body", "browser.body_name"), ("Component", "browser.component_name")] {
if let Some(number) = text.strip_prefix(prefix)
&& !number.is_empty()
&& number.chars().all(|c| c.is_ascii_digit())
{
return self.format(id, &[("number", number)]);
}
}
for (prefix, id, arg) in [("Units: ", "browser.units", "units"), ("Precision: ", "browser.precision", "precision")] {
if let Some(value) = text.strip_prefix(prefix) {
return self.format(id, &[(arg, value)]);
}
}
if let Some(count) = text.strip_prefix("Parameters (").and_then(|s| s.strip_suffix(')')) {
return self.format("browser.parameters", &[("count", count)]);
}
if let Some(count) = text.strip_suffix(" degrees of freedom")
&& !count.is_empty()
&& count.chars().all(|c| c.is_ascii_digit())
{
return self.format("sketch.degrees_of_freedom", &[("count", count)]);
}
self.label(text).to_string()
}
}
/// One-pass substitution: argument values are literal, even when they contain braces.
/// Missing arguments remain visible rather than crashing or silently removing text.
fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
let mut output = String::with_capacity(template.len());
let mut remaining = template;
while let Some((before, after)) = remaining.split_once('{') {
output.push_str(before);
let Some((name, rest)) = after.split_once('}') else {
output.push('{');
output.push_str(after);
return output;
};
if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) {
output.push_str(value);
} else {
output.push('{');
output.push_str(name);
output.push('}');
}
remaining = rest;
}
output.push_str(remaining);
output
}
thread_local! {
// Helpers without an app reference share a render scope. Nested renders restore
// their caller's locale; no owned language strings or persistent global setting.
static CURRENT: Cell<Locale> = const { Cell::new(Locale(&ENGLISH)) };
}
#[must_use]
pub struct LanguageScope {
previous: Locale,
// Drop on the thread where the scope was entered.
_thread: std::marker::PhantomData<std::rc::Rc<()>>,
}
pub fn scope(id: &str) -> LanguageScope {
let previous = CURRENT.with(|current| current.replace(Locale::for_language(id)));
LanguageScope { previous, _thread: std::marker::PhantomData }
}
impl Drop for LanguageScope {
fn drop(&mut self) {
CURRENT.with(|current| current.set(self.previous));
}
}
pub fn tr(id: &str) -> &str {
CURRENT.with(|current| current.get().text(id))
}
pub fn tr_label(label: &str) -> &str {
CURRENT.with(|current| current.get().label(label))
}
pub fn format(id: &str, args: &[(&str, &str)]) -> String {
CURRENT.with(|current| current.get().format(id, args))
}
pub fn tr_dyn(text: &str) -> String {
CURRENT.with(|current| current.get().dynamic(text))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn placeholders(text: &str) -> BTreeSet<&str> {
let mut result = BTreeSet::new();
let mut rest = text;
while let Some((_, after)) = rest.split_once('{') {
let (name, remaining) = after.split_once('}').expect("unclosed placeholder");
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
result.insert(name);
rest = remaining;
}
result
}
#[test]
fn catalogs_have_valid_ids_and_matching_placeholders() {
let english: Messages = serde_json::from_str(ENGLISH.source).unwrap();
let mut ids = BTreeSet::new();
for catalog in LOCALES {
assert!(ids.insert(catalog.id), "duplicate locale");
let messages: Messages = serde_json::from_str(catalog.source).unwrap();
assert!(!messages.is_empty());
for (id, text) in messages {
let original = english.get(&id).expect("unknown message ID");
assert_eq!(placeholders(&text), placeholders(original), "{}: {id}", catalog.id);
}
}
}
#[test]
fn direct_ui_message_ids_exist_in_english_catalog() {
// Catch a misspelled key before it is shown verbatim in the UI. Engine-table labels
// deliberately use the separate label adapter and fall back to their source text.
for source in [
include_str!("toolbar.rs"),
include_str!("prefs.rs"),
include_str!("documents.rs"),
include_str!("dialogs.rs"),
include_str!("browser.rs"),
include_str!("sketch_palette.rs"),
] {
for call in [".tr(\"", "i18n::tr(\"", "i18n::format(\""] {
for tail in source.split(call).skip(1) {
let id = tail.split('"').next().unwrap();
assert!(ENGLISH.messages().contains_key(id), "missing English message: {id}");
}
}
}
}
#[test]
fn keys_and_engine_labels_translate_with_english_fallback() {
let ja = Locale::for_language("ja");
assert_eq!(ja.text("ui.preferences"), "環境設定");
assert_eq!(ja.label("Extrude"), "押し出し");
assert_eq!(ja.label("CREATE SKETCH"), "スケッチを作成");
assert_eq!(ja.label("NonExistentKey"), "NonExistentKey");
assert_eq!(Locale::for_language("unknown").text("ui.preferences"), "Preferences");
static PARTIAL: Catalog = Catalog::new("test", "Test", r#"{"ui.preferences":"Prefs"}"#);
assert_eq!(Locale(&PARTIAL).text("ui.preferences"), "Prefs");
assert_eq!(Locale(&PARTIAL).text("ui.extrude"), "Extrude");
}
#[test]
fn templates_allow_reordering_and_literal_braces_in_values() {
assert_eq!(interpolate("{second} then {first}", &[("first", "{second}"), ("second", "B")]), "B then {second}");
assert_eq!(interpolate("{missing} / {", &[]), "{missing} / {");
assert_eq!(Locale::for_language("ja").format("document.save_changes", &[("name", "My {design}")]), "My {design} への変更を保存しますか?");
assert_eq!(Locale::for_language("en").format("dialog.selected_count", &[("count", "2")]), "↖ 2 selected");
}
#[test]
fn browser_patterns_preserve_numbers_and_custom_names() {
let ja = Locale::for_language("ja");
assert_eq!(ja.dynamic("Sketch1"), "スケッチ1");
assert_eq!(ja.dynamic("Body2"), "ボディ2");
assert_eq!(ja.dynamic("Component12"), "コンポーネント12");
assert_eq!(ja.dynamic("Units: mm"), "単位: mm");
assert_eq!(ja.dynamic("Precision: 3"), "精度: 3");
assert_eq!(ja.dynamic("Parameters (5)"), "パラメータ (5)");
assert_eq!(ja.dynamic("4 degrees of freedom"), "自由度 4");
assert_eq!(ja.dynamic("Sketch of bracket"), "Sketch of bracket");
assert_eq!(Locale::for_language("en").dynamic("Sketch1"), "Sketch1");
}
#[test]
fn korean_catalog_translates_keys_labels_and_browser_patterns() {
let ko = Locale::for_language("ko");
assert_eq!(ko.name(), "한국어");
assert!(is_supported("ko"));
assert_eq!(ko.text("ui.preferences"), "환경 설정");
assert_eq!(ko.label("Extrude"), "돌출");
assert_eq!(ko.label("CREATE SKETCH"), "스케치 만들기");
assert_eq!(ko.label("NonExistentKey"), "NonExistentKey");
assert_eq!(ko.format("document.save_changes", &[("name", "My {design}")]), "My {design}의 변경 사항을 저장하시겠습니까?");
assert_eq!(ko.format("dialog.selected_count", &[("count", "2")]), "↖ 2개 선택됨");
assert_eq!(ko.dynamic("Sketch1"), "스케치1");
assert_eq!(ko.dynamic("Body2"), "바디2");
assert_eq!(ko.dynamic("Component12"), "컴포넌트12");
assert_eq!(ko.dynamic("Units: mm"), "단위: mm");
assert_eq!(ko.dynamic("Precision: 3"), "정밀도: 3");
assert_eq!(ko.dynamic("Parameters (5)"), "매개변수 (5)");
assert_eq!(ko.dynamic("4 degrees of freedom"), "자유도 4개");
assert_eq!(ko.dynamic("Sketch of bracket"), "Sketch of bracket");
}
#[test]
fn render_scopes_restore_the_previous_language() {
let _english = scope("en");
{
let _japanese = scope("ja");
assert_eq!(tr("ui.preferences"), "環境設定");
{
let _nested = scope("en");
assert_eq!(tr("ui.preferences"), "Preferences");
}
assert_eq!(tr("ui.preferences"), "環境設定");
}
assert_eq!(tr("ui.preferences"), "Preferences");
}
}