diff --git a/Cargo.lock b/Cargo.lock index 6af8dbc2..56413f89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1624,6 +1624,7 @@ dependencies = [ "rayon", "serde", "serde_json", + "sys-locale", "toml", "web-time", ] @@ -4949,6 +4950,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "tag_ptr" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c35c6738..2c6ffc83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,8 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" cpal = "0.16" +# The operating system's interface language (MIT/Apache-2.0, no dependencies on desktop). +sys-locale = "0.3" # Local co-development against a FilmCraft checkout next to this repo: uncomment. # [patch."https://github.com/storytold/filmcraft"] diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a0d305e9..376d0d98 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -22,6 +22,13 @@ pub const PREFS_VERSION: u32 = 2; /// Settings file name in the config store. pub const PREFS_FILE: &str = "prefs.json"; +/// Settings ▸ General ▸ Language: (label, `general.language` value). +pub const LANGUAGES: &[(&str, &str)] = &[("Match System", "system"), ("English", "en"), ("日本語", "ja")]; + +fn is_language(v: &str) -> bool { + LANGUAGES.iter().any(|(_, l)| *l == v) +} + /// Unknown keys inside a page, kept so a newer version's settings survive a round trip. type Extra = BTreeMap; @@ -44,8 +51,9 @@ macro_rules! page { } page!(General { - /// Interface language (`en` or `ja`). - language: String = "en".into(), + /// Interface language: `system` (the operating system's, where EffectCraft has it, else + /// English), `en` or `ja`. + language: String = "system".into(), /// Levels of Undo (1–99). undo_levels: u32 = 32, /// Path Point and Handle Size (px). @@ -417,8 +425,8 @@ impl Prefs { pub fn normalize(&mut self) { self.version = PREFS_VERSION; let g = &mut self.general; - if !matches!(g.language.as_str(), "en" | "ja") { - g.language = "en".into(); + if !is_language(&g.language) { + g.language = General::default().language; } g.undo_levels = g.undo_levels.clamp(1, 99); g.path_point_size = g.path_point_size.clamp(3, 20); @@ -458,8 +466,9 @@ impl Prefs { /// Set the value at a dotted key. The key must exist and the value must have its type. pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> { - if key == "general.language" && !matches!(value.as_str(), Some("en" | "ja")) { - return Err("`general.language` expects `en` or `ja`".into()); + if key == "general.language" && !value.as_str().is_some_and(is_language) { + let all: Vec<&str> = LANGUAGES.iter().map(|(_, l)| *l).collect(); + return Err(format!("`general.language` expects one of {}", all.join(", "))); } let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?; let ptr = format!("/{}", key.replace('.', "/")); @@ -885,7 +894,7 @@ pub fn pages() -> Vec { id: "general", title: "General", items: vec![ - s("general.language", "Language", Kind::Choice(&[("English", "en"), ("日本語", "ja")]), true), + s("general.language", "Language", Kind::Choice(LANGUAGES), true), s("general.undoLevels", "Levels of Undo", Kind::Int(1, 99, ""), true), s("general.pathPointSize", "Path Point and Handle Size", Kind::Int(3, 20, "px"), true), s("general.recentItems", "Recent Projects Shown", Kind::Int(1, 30, ""), true), diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 4afa08fc..dc3d9888 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -491,7 +491,8 @@ fn memory_tick_never_waits_for_the_system() { fn interface_language_is_validated_persisted_and_backward_compatible() { let store = Arc::new(MemoryConfig::default()); let mut s = Session { config: Some(store.clone()), ..Default::default() }; - assert_eq!(s.prefs.general.language, "en"); + assert_eq!(s.prefs.general.language, "system", "Match System by default (#229)"); + s.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("ja")); let saved = store.read(PREFS_FILE).unwrap(); @@ -504,9 +505,10 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { reloaded.load_settings(); assert_eq!(reloaded.prefs.general.language, "ja"); reloaded.execute("prefs.reset", json!({"page": "general"})).unwrap(); - assert_eq!(reloaded.prefs.general.language, "en"); - assert_eq!(Prefs::from_json(r#"{"general":{"undoLevels":17}}"#).general.language, "en"); - assert_eq!(Prefs::from_json(r#"{"general":{"language":"unknown"}}"#).general.language, "en"); + assert_eq!(reloaded.prefs.general.language, "system"); + assert_eq!(Prefs::from_json(r#"{"general":{"undoLevels":17}}"#).general.language, "system"); + assert_eq!(Prefs::from_json(r#"{"general":{"language":"unknown"}}"#).general.language, "system"); + assert_eq!(Prefs::from_json(r#"{"general":{"language":"en"}}"#).general.language, "en", "a chosen language stays"); } #[test] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5bdfbf4f..2d07038d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -30,6 +30,9 @@ web-time = { workspace = true } kurbo = { workspace = true } image = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +sys-locale = { workspace = true } + [dev-dependencies] egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } pollster = "0.4" diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bdbeafb3..f864b064 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -7,7 +7,31 @@ use crate::EffectcraftApp; use effectcraft_engine::menus::MenuEntry; pub(crate) fn japanese(app: &EffectcraftApp) -> bool { - app.session.prefs.general.language == "ja" + match app.session.prefs.general.language.as_str() { + "system" => system_language() == "ja", + l => l == "ja", + } +} + +/// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language +/// where EffectCraft has it, else English (#229), as After Effects installs in the system's +/// language. The browser build stays in English: it has no Japanese font of its own. +fn system_language() -> &'static str { + #[cfg(not(target_arch = "wasm32"))] + { + static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); + LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref())) + } + #[cfg(target_arch = "wasm32")] + "en" +} + +/// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`). +fn supported(locale: Option<&str>) -> &'static str { + match locale.and_then(|l| l.split(['-', '_']).next()) { + Some(l) if l.eq_ignore_ascii_case("ja") => "ja", + _ => "en", + } } pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> &'a str { @@ -723,6 +747,7 @@ mod tests { #[test] fn language_changes_native_labels_without_changing_commands_or_parameters() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); let en = crate::native_menu::build(&app); let state = crate::native_menu::state_key(&app); app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); @@ -749,6 +774,26 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(label(&app, "", "Composition"), "Composition"); } + #[test] + fn match_system_uses_the_system_language_where_there_is_a_catalog() { + for (locale, language) in [ + (Some("ja-JP"), "ja"), + (Some("ja"), "ja"), + (Some("JA_jp.UTF-8"), "ja"), + (Some("en-US"), "en"), + (Some("jv-ID"), "en"), + (Some("de-DE"), "en"), + (None, "en"), + ] { + assert_eq!(supported(locale), language, "{locale:?}"); + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + assert_eq!(app.session.prefs.general.language, "system", "the default"); + assert_eq!(japanese(&app), system_language() == "ja"); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); + assert!(japanese(&app)); + } + #[test] fn general_settings_exposes_the_language_automation_id() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/docs/preferences.md b/docs/preferences.md index ee1fe80c..b20ee8b7 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -29,7 +29,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`en` / `ja`), Settings ▸ General ▸ Language; menu labels change immediately. Native Japanese UI uses installed system fonts; the web host must supply a Japanese font. +- `general.language`: interface language (`system` / `en` / `ja`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise; the browser build stays in English with it. Native Japanese UI uses installed system fonts; the web host must supply a Japanese font. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown