fix(i18n): follow native UI languages on first launch (#661)

* fix(i18n): follow native UI languages on first launch

* i18n system tests: German and Portuguese are supported now; use Swedish as the unsupported tag

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-07 16:52:55 -04:00
1 parent 1353917f15
commit 2cbb13a79d
9 files changed
+264 -57

No files matched your search

Generated
+10
View File
@@ -3792,6 +3792,7 @@ dependencies = [
"rayon",
"serde",
"serde_json",
"sys-locale",
]
[[package]]
@@ -4952,6 +4953,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 = "tempfile"
version = "3.27.0"
+2
View File
@@ -32,6 +32,8 @@ base64 = "0.22"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
# Safe native UI-language APIs (Windows/macOS); no subprocess or web dependency.
sys-locale = "=0.3.2"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] }
+4
View File
@@ -138,6 +138,10 @@ fn main() {
if let Outcome::Done(v) = handle(harness.state_mut(), &ctx, &req) {
println!("perf: {}", v["result"]["perf"]["timings"]);
}
println!(
"language: {}",
serde_json::json!({"preference": harness.state().session.prefs().interface.language, "resolved": photocraft_ui_egui::i18n::current().code()})
);
let img = harness.render().expect("render");
img.save(&out).expect("save png");
println!("wrote {out} ({}×{})", img.width(), img.height());
+70 -2
View File
@@ -114,9 +114,13 @@ fn korean_glyphs_remain_available_after_repeated_language_switches() {
}
fn harness() -> egui_kittest::Harness<'static, PhotocraftApp> {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
harness_with_services(Services::default())
}
fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, PhotocraftApp> {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
app.run("file.new", json!({"name": "User layer name", "width": 64, "height": 64})).expect("new document");
app
});
@@ -124,6 +128,70 @@ fn harness() -> egui_kittest::Harness<'static, PhotocraftApp> {
harness
}
#[test]
fn first_launch_renders_the_system_language_without_overwriting_auto() {
for (tag, code) in [("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("fr-CA", "fr")] {
system::with_system_tags(&[tag], || {
with_language(Lang::EN, || {
let h = harness();
assert_eq!(h.state().session.prefs().interface.language, "auto");
assert_eq!(current(), lang(code), "{tag}");
assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang(code), "File")), "first-launch menu: {tag}");
});
});
}
}
#[test]
fn missing_or_unsupported_system_languages_render_english() {
for tags in [vec![], vec!["sv-SE", "ar-SA"]] {
system::with_system_tags(&tags, || {
with_language(Lang::EN, || {
let h = harness();
assert_eq!(h.state().session.prefs().interface.language, "auto");
assert_eq!(current(), Lang::EN);
assert!(drawn_text(&h).iter().any(|text| text == "File"));
});
});
}
}
#[test]
fn older_preferences_without_a_language_follow_the_system() {
system::with_system_tags(&["fr-CA"], || {
with_language(Lang::EN, || {
let services = Services { load_prefs: Some(Box::new(|| Some(r#"{"interface":{"uiScale":"125"}}"#.into()))), ..Default::default() };
let h = harness_with_services(services);
assert_eq!(h.state().session.prefs().interface.language, "auto");
assert_eq!(h.state().session.prefs().interface.ui_scale, photocraft_engine::prefs::UiScale::P125);
assert_eq!(current(), lang("fr"));
});
});
}
#[test]
fn an_explicit_language_survives_restart_on_a_different_system_language() {
let saved = system::with_system_tags(&["ja-JP"], || {
with_language(Lang::EN, || {
let mut h = harness();
assert_eq!(current(), lang("ja"));
h.state_mut().run("prefs.set", json!({"path":"interface.language","value":"ko"})).expect("choose language");
h.step();
assert_eq!(current(), lang("ko"));
h.state().session.prefs_to_json()
})
});
system::with_system_tags(&["fr-FR"], || {
with_language(Lang::EN, || {
let services = Services { load_prefs: Some(Box::new(move || Some(saved.clone()))), ..Default::default() };
let h = harness_with_services(services);
assert_eq!(h.state().session.prefs().interface.language, "ko");
assert_eq!(current(), lang("ko"));
assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang("ko"), "File")));
});
});
}
#[test]
fn a_control_request_changes_the_drawing_language_in_the_same_frame() {
with_language(Lang::EN, || {
+3 -55
View File
@@ -19,6 +19,9 @@
//! translators may reorder placeholders freely.
mod catalog;
mod system;
pub use system::system_lang;
use std::cell::Cell;
use std::sync::OnceLock;
@@ -205,52 +208,6 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
cands.iter().find_map(|c| Lang::from_code(c))
}
/// The system language (cached). English when it can't be determined.
pub fn system_lang() -> Lang {
// Tests drive the UI by its English labels whatever the developer's locale is.
if cfg!(test) {
return Lang::EN;
}
static SYSTEM: OnceLock<Lang> = OnceLock::new();
*SYSTEM.get_or_init(detect_system_lang)
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_system_lang() -> Lang {
for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Some(l) = std::env::var(var).ok().filter(|v| !v.is_empty()).and_then(|v| lang_from_tag(&v)) {
return l;
}
}
// Apps started from the Finder don't inherit LANG: use the macOS preferred-languages list.
// The absolute path keeps a `defaults` earlier on PATH from running; any failure means English.
#[cfg(target_os = "macos")]
if let Ok(out) = std::process::Command::new("/usr/bin/defaults").args(["read", "-g", "AppleLanguages"]).output()
&& out.status.success()
&& let Some(l) = first_supported(&String::from_utf8_lossy(&out.stdout))
{
return l;
}
// Windows sets no LANG: fall back to the OS locale the text engine already reads for its CJK
// font order (`HKCU\Control Panel\International` › `LocaleName`, e.g. `zh-TW`; on macOS the
// preferences plist). `PHOTOCRAFT_LOCALE` overrides it there too.
if let Some(l) = photocraft_text::cjk::ui_locale().and_then(lang_from_tag) {
return l;
}
Lang::EN
}
/// The first supported language in a `defaults read` list like `(\n "ja-JP",\n "en-US"\n)`.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn first_supported(list: &str) -> Option<Lang> {
list.split(['(', ')', ',', '"', '\n']).map(str::trim).filter(|s| !s.is_empty()).find_map(lang_from_tag)
}
#[cfg(target_arch = "wasm32")]
fn detect_system_lang() -> Lang {
Lang::EN
}
thread_local! {
// Independent app/test threads must not change each other's drawing language.
static CURRENT: Cell<Lang> = const { Cell::new(Lang::EN) };
@@ -401,15 +358,6 @@ mod tests {
assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]);
}
#[test]
fn macos_language_list_is_parsed() {
assert_eq!(first_supported("(\n \"ja-JP\",\n \"en-US\"\n)\n"), Some(JA()));
assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr"));
assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Lang::from_code("de"));
assert_eq!(first_supported("(\n \"zh-Hant-TW\",\n \"en-US\"\n)\n"), Some(ZH()));
assert_eq!(first_supported("("), None);
}
#[test]
fn preferences_resolve_with_fallback() {
assert_eq!(Lang::from_pref("ja"), JA());
+122
View File
@@ -0,0 +1,122 @@
//! Native UI-language preferences, queried once without subprocesses or registry parsing.
use super::{Lang, lang_from_tag};
#[cfg(not(target_arch = "wasm32"))]
const MAX_SYSTEM_TAGS: usize = 64;
#[cfg(not(target_arch = "wasm32"))]
const MAX_SYSTEM_TAG_BYTES: usize = 128;
/// Resolve Auto against the current registry. Cache the OS's tags, rather than a language,
/// so matching remains separate from platform detection.
/// Missing or unsupported system preferences always resolve to English.
pub fn system_lang() -> Lang {
#[cfg(test)]
{
TEST_SYSTEM_TAGS.with(|tags| resolve(&tags.borrow()))
}
#[cfg(not(test))]
{
static SYSTEM: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
resolve(SYSTEM.get_or_init(detect_system_tags))
}
}
fn resolve(tags: &[String]) -> Lang {
tags.iter().find_map(|tag| lang_from_tag(tag)).unwrap_or(Lang::EN)
}
#[cfg(not(target_arch = "wasm32"))]
fn bounded_tags(tags: impl IntoIterator<Item = String>) -> Vec<String> {
tags.into_iter()
.take(MAX_SYSTEM_TAGS)
.filter(|tag| {
!tag.trim().is_empty()
&& tag.len() <= MAX_SYSTEM_TAG_BYTES
&& tag.trim().bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'@'))
})
.map(|tag| tag.trim().to_owned())
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_system_tags() -> Vec<String> {
if let Ok(tag) = std::env::var("PHOTOCRAFT_LOCALE")
&& !tag.trim().is_empty()
{
return bounded_tags([tag]);
}
// sys-locale exposes safe wrappers for GetUserPreferredUILanguages (Windows) and
// CFLocaleCopyPreferredLanguages (macOS); Unix uses its standard locale environment.
// A failure in platform interop must also leave the app usable in English.
std::panic::catch_unwind(|| bounded_tags(sys_locale::get_locales())).unwrap_or_default()
}
#[cfg(all(not(test), target_arch = "wasm32"))]
fn detect_system_tags() -> Vec<String> {
Vec::new()
}
#[cfg(test)]
thread_local! {
// Keep ordinary tests independent of the developer's locale, without mutating OS or
// process-global preferences. Startup tests supply the OS result on their own thread.
static TEST_SYSTEM_TAGS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
}
#[cfg(test)]
pub(super) fn with_system_tags<R>(tags: &[&str], run: impl FnOnce() -> R) -> R {
struct Restore(Vec<String>);
impl Drop for Restore {
fn drop(&mut self) {
TEST_SYSTEM_TAGS.set(std::mem::take(&mut self.0));
}
}
let _restore = Restore(TEST_SYSTEM_TAGS.replace(tags.iter().map(|tag| (*tag).to_owned()).collect()));
run()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_preferences_are_negotiated_in_order() {
for (tags, expected) in [
(vec!["ja-JP", "en-US"], "ja"),
(vec!["fr-CA", "en-US"], "fr"),
(vec!["sv-SE", "ko-KR", "fr-FR"], "ko"),
(vec!["zh-Hant-HK", "zh-CN"], "zh-hant"),
(vec!["zh-Hans-CN", "zh-TW"], "zh-hans"),
(vec!["en-US", "ru-RU"], "en"),
(vec!["sv-SE", "ar-SA"], "en"),
(vec!["de-AT", "en-US"], "de"),
(vec!["pt-PT"], "pt-br"),
(vec![], "en"),
] {
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));
}
}
#[test]
fn test_system_preferences_restore_and_remain_thread_local() {
with_system_tags(&["ko-KR"], || {
with_system_tags(&["fr-FR"], || assert_eq!(system_lang().code(), "fr"));
assert_eq!(system_lang().code(), "ko");
std::thread::spawn(|| assert_eq!(system_lang(), Lang::EN)).join().expect("locale test thread");
assert!(std::panic::catch_unwind(|| with_system_tags(&["ja-JP"], || panic!("test unwind"))).is_err());
assert_eq!(system_lang().code(), "ko");
});
assert_eq!(system_lang(), Lang::EN);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_locale_results_are_bounded_and_trimmed() {
assert_eq!(bounded_tags([" fr-CA ".into(), "".into(), " ".into(), "fr-\0".into(), "x".repeat(129)]), ["fr-CA"]);
assert_eq!(bounded_tags(std::iter::repeat_n("en-US".into(), 100)).len(), MAX_SYSTEM_TAGS);
let detected = detect_system_tags();
assert!(detected.len() <= MAX_SYSTEM_TAGS);
assert!(detected.iter().all(|tag| !tag.is_empty() && tag.len() <= MAX_SYSTEM_TAG_BYTES));
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Use the actual platform UI-language API and the production (non-cfg(test)) Auto path.
#![cfg(not(target_arch = "wasm32"))]
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_ui_egui::{PhotocraftApp, i18n};
#[test]
fn native_first_launch_follows_os_ui_preferences() {
let tags: Vec<String> = match std::env::var("PHOTOCRAFT_LOCALE").ok().filter(|tag| !tag.trim().is_empty()) {
Some(tag) => vec![tag],
None => sys_locale::get_locales().take(64).collect(),
};
let expected = tags
.iter()
.filter(|tag| tag.len() <= 128 && tag.trim().bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'@')))
.find_map(|tag| i18n::lang_from_tag(tag.trim()))
.unwrap_or(i18n::Lang::EN);
eprintln!("native UI-language preferences: {tags:?}; resolved: {}", expected.code());
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default())
});
h.run_steps(4);
assert_eq!(h.state().session.prefs().interface.language, "auto");
assert_eq!(i18n::current(), expected, "native language preferences: {tags:?}");
assert!(h.query_by_label(i18n::tr(expected, "File")).is_some(), "the first-launch menu must use the OS UI language");
}
+1
View File
@@ -58,6 +58,7 @@ If the device is lost while running (#243), every GPU entry point checks the dev
| `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) |
| `PHOTOCRAFT_FX_NOCACHE=1` | Bypass the CPU layer-effect map cache (`compose::effect_maps`) |
| `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) |
| `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). |
| `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild |
| `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change |
+25
View File
@@ -40,6 +40,31 @@ native system locale; an unsupported code follows the same fallback. Regional ta
Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the
Brazilian Portuguese catalog.
### First launch and system language
With no saved preference, `interface.language` defaults to `auto`, so the first rendered
window uses the first supported system UI language. An older preferences file without this
field also uses Auto. If detection fails or none of the preferred languages is registered,
the UI uses English (`en`). A saved manual language choice takes precedence on later launches;
startup never overwrites it or writes the detected language into the preference file.
The native-only, pinned `sys-locale` dependency provides safe Rust access to the platform APIs:
- **Windows:** `GetUserPreferredUILanguages`, in the user's preferred order. This uses UI
languages rather than the Region setting for dates and numbers; they can differ.
- **macOS:** `CFLocaleCopyPreferredLanguages`, in preferred order, without starting `defaults`
or depending on a particular `.plist` representation.
- **Linux/BSD:** the platform's standard locale environment, including `LANGUAGE` lists.
`PHOTOCRAFT_LOCALE` is an optional single-tag override for reproducible launches; an unsupported
override falls back to English. Empty overrides use the system. Unix locale variables do not
override Windows/macOS UI-language preferences. Tags are read once per process (at most 64,
128 bytes each) and matched against the supported language registry. Changes to the
OS language list itself take effect at the next launch. The web build keeps its existing
English Auto fallback until browser-locale detection is implemented.
API implementation and licensing: [sys-locale](https://github.com/1Password/sys-locale).
Agents use the existing `prefs.set` command through CLI, MCP or the desktop control channel:
```json