Merge pull request #241 from storytold/feat/229-system-language

Settings: Language ▸ Match System follows the system language (#229)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-08 12:58:55 +07:00
commit e1062ab81b
7 files changed
+84 -13

No files matched your search

Generated
+10
View File
@@ -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"
+2
View File
@@ -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"]
+16 -7
View File
@@ -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<String, Value>;
@@ -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<Page> {
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),
+6 -4
View File
@@ -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]
+3
View File
@@ -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"
+46 -1
View File
@@ -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());
+1 -1
View File
@@ -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