mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
9 files changed
+264
-57
No files matched your search
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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, || {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user