diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 77a92919..9eebed53 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -569,17 +569,45 @@ const CHINESE_FAMILIES: &[&str] = &[ ]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; -/// The CJK language to try first for Han ideographs, from the locale environment (`LANG`, -/// `LC_ALL`, `LC_CTYPE`; `EFFECTCRAFT_CJK_LOCALE` overrides them). Japanese when nothing says -/// otherwise: the ambiguity only matters for ideographs, and kana / hangul in the same text pick -/// their language regardless. -fn han_order() -> [FallbackScript; 3] { - let tag = ["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"] - .iter() - .filter_map(|k| std::env::var(k).ok()) - .map(|v| v.trim().to_ascii_lowercase()) - .find(|v| !v.is_empty() && v != "c" && v != "posix") - .unwrap_or_default(); +/// The CJK locale the host asked fallbacks to prefer, when it has one (see [`set_cjk_locale`]). +static REQUESTED_CJK_LOCALE: RwLock> = RwLock::new(None); + +/// Ask CJK font fallbacks to prefer `tag`'s language for Han ideographs instead of the locale +/// environment, so a Chinese interface is drawn with a Chinese face on a machine whose locale is +/// Japanese (and the other way round). The interface sets this from its language setting; an empty +/// tag, `c` or `posix` clears the request and returns to the environment. Process-global, like the +/// environment it replaces. +pub fn set_cjk_locale(tag: &str) { + let tag = tag.trim().to_ascii_lowercase(); + let requested = if tag.is_empty() || tag == "c" || tag == "posix" { None } else { Some(tag) }; + *REQUESTED_CJK_LOCALE.write().unwrap_or_else(|e| e.into_inner()) = requested; + // The fallback cache answers per character block and preferred face, so a change of language + // must drop it. + fallback_cache().write().unwrap_or_else(|e| e.into_inner()).clear(); +} + +fn requested_cjk_locale() -> Option { + REQUESTED_CJK_LOCALE.read().unwrap_or_else(|e| e.into_inner()).clone() +} + +/// The CJK locale to use: [`set_cjk_locale`] when the host asked for one, otherwise the locale +/// environment (`LANG`, `LC_ALL`, `LC_CTYPE`; `EFFECTCRAFT_CJK_LOCALE` overrides them). +fn active_cjk_locale() -> String { + requested_cjk_locale().unwrap_or_else(|| { + ["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"] + .iter() + .filter_map(|k| std::env::var(k).ok()) + .map(|v| v.trim().to_ascii_lowercase()) + .find(|v| !v.is_empty() && v != "c" && v != "posix") + .unwrap_or_default() + }) +} + +/// The CJK language to try first for Han ideographs. Japanese when nothing says otherwise: the +/// ambiguity only matters for ideographs, and kana / hangul in the same text pick their language +/// regardless. +fn han_order_for(tag: &str) -> [FallbackScript; 3] { + let tag = tag.trim().to_ascii_lowercase(); if tag.starts_with("zh") { [FallbackScript::Han, FallbackScript::Japanese, FallbackScript::Korean] } else if tag.starts_with("ko") { @@ -589,6 +617,11 @@ fn han_order() -> [FallbackScript; 3] { } } +/// The order for the locale in force ([`active_cjk_locale`]). +fn han_order() -> [FallbackScript; 3] { + han_order_for(&active_cjk_locale()) +} + fn families_for(s: FallbackScript) -> &'static [&'static str] { match s { FallbackScript::Japanese => JAPANESE_FAMILIES, @@ -921,6 +954,23 @@ mod tests { assert_eq!(fallback_for('\u{e001}', inter), id, "fallback finds the drawn one"); } + #[test] + fn the_han_order_follows_the_cjk_locale() { + // Chinese first for any Chinese tag, whether or not it names a script. + for tag in ["zh", "zh-CN", "zh_Hans", "ZH-Hant-TW.UTF-8", "zh_MO"] { + assert_eq!(han_order_for(tag)[0], FallbackScript::Han, "{tag}"); + } + assert_eq!(han_order_for("ko-KR"), [FallbackScript::Korean, FallbackScript::Japanese, FallbackScript::Han]); + // Japanese, and anything else, keeps the Japanese face first for Han text. + for tag in ["ja_JP.UTF-8", "en-US", "de", ""] { + assert_eq!(han_order_for(tag), [FallbackScript::Japanese, FallbackScript::Han, FallbackScript::Korean], "{tag}"); + } + // The host's request is what `han_order` reads (the ui test covers the effect on the + // registered fallbacks; this only checks that clearing it returns to the environment). + assert_eq!(requested_cjk_locale(), None, "tests run without a request"); + assert_eq!(han_order(), han_order_for(&active_cjk_locale())); + } + #[test] fn metrics_and_coverage() { let f = face(resolve("Inter", "Regular").face); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3735df98..b615af32 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2244,7 +2244,7 @@ mod tests { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); let ctx = egui::Context::default(); let tokens = crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark); - crate::theme::install(&ctx, &tokens); + crate::theme::install(&ctx, &tokens, &app.session.prefs.general.language); crate::panels::settings::open(&mut app, "general"); let mut out = ctx.run_ui(egui::RawInput::default(), |_| { crate::panels::settings::show(&mut app, &ctx, &tokens); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d659543..9fe8f6e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1569,7 +1569,7 @@ impl eframe::App for EffectcraftApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.apply_gpu_failure(ctx); if !self.styled { - theme::install(ctx, &self.tokens); + theme::install(ctx, &self.tokens, &self.session.prefs.general.language); fit_window(ctx); self.styled = true; ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 80a3e88c..55dd5876 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -548,7 +548,7 @@ mod tests { fn app() -> (EffectcraftApp, egui::Context) { let ctx = egui::Context::default(); let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); - crate::theme::install(&ctx, &app.tokens); + crate::theme::install(&ctx, &app.tokens, &app.session.prefs.general.language); app.session.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360})).unwrap(); (app, ctx) } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index f4de5f21..9444757e 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -844,7 +844,7 @@ mod tests { fn premiere_export_dialog() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); let ctx = egui::Context::default(); - crate::theme::install(&ctx, &app.tokens); + crate::theme::install(&ctx, &app.tokens, &app.session.prefs.general.language); app.session.execute("comp.new", json!({"name": "Spot", "width": 64, "height": 36, "frameRate": 25, "duration": 1})).unwrap(); app.session.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); crate::menus::invoke(&mut app, &ctx, "file.exportTimeline", json!({})).unwrap(); diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 904e5f2f..8bb7e36c 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -540,7 +540,7 @@ mod tests { s.prefs.push_recent("/projects/Alpha.ecproj"); let mut app = EffectcraftApp::new(s); let ctx = egui::Context::default(); - crate::theme::install(&ctx, &app.tokens); + crate::theme::install(&ctx, &app.tokens, &app.session.prefs.general.language); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); ctx.run_ui(Default::default(), |ui| { app.auto.begin_frame(); @@ -573,7 +573,7 @@ mod tests { app.ui.start_screen = true; app.ui.home_learn = true; let ctx = egui::Context::default(); - crate::theme::install(&ctx, &app.tokens); + crate::theme::install(&ctx, &app.tokens, &app.session.prefs.general.language); let frame = |app: &mut EffectcraftApp| { ctx.run_ui(Default::default(), |ui| { app.auto.begin_frame(); diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index 2dbc1786..aac942d2 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -194,7 +194,7 @@ mod tests { fn gallery_lists_renders_and_opens_templates() { let mut app = app_with_store(); let ctx = egui::Context::default(); - crate::theme::install(&ctx, &app.tokens); + crate::theme::install(&ctx, &app.tokens, &app.session.prefs.general.language); // File ▸ New ▸ New Project from Template… opens the Templates tab. let ev = app.session.execute("file.newFromTemplate", json!({})).unwrap(); assert_eq!(ev["frontend"], "file.newFromTemplate"); @@ -250,7 +250,7 @@ mod tests { fn my_custom_rgb_dialog() { let mut app = app_with_store(); let ctx = egui::Context::default(); - crate::theme::install(&ctx, &app.tokens); + crate::theme::install(&ctx, &app.tokens, &app.session.prefs.general.language); // Menu check marks follow the active comp's viewer. app.session.execute("comp.new", json!({"name": "C", "width": 64, "height": 48})).unwrap(); let item = crate::menus::menu_items(&app).into_iter().find(|m| m.id == "view.customRgb").expect("menu item"); diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index 696da1a7..9e0e7067 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -170,7 +170,7 @@ mod tests { fn app() -> (EffectcraftApp, egui::Context) { let ctx = egui::Context::default(); let app = EffectcraftApp::new(effectcraft_engine::Session::default()); - crate::theme::install(&ctx, &app.tokens); + crate::theme::install(&ctx, &app.tokens, &app.session.prefs.general.language); (app, ctx) } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bfe4de75..e635ee51 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -273,8 +273,11 @@ static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium. static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); static JETBRAINS_MONO: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); -/// Install fonts (Inter, Inter Medium/SemiBold, JetBrains Mono) and egui visuals. -pub fn install(ctx: &egui::Context, t: &Tokens) { +/// Install fonts (Inter, Inter Medium/SemiBold, JetBrains Mono) and egui visuals. `language` is the +/// stored `general.language` value (`en`, `ja`, `zh-hans`, `zh-hant` or `system`): the CJK fallback +/// that draws the interface's own script is registered first, so a Chinese interface is drawn by a +/// Chinese face and a Japanese one by a Japanese face. +pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(INTER_REGULAR))); fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(INTER_MEDIUM))); @@ -284,26 +287,78 @@ pub fn install(ctx: &egui::Context, t: &Tokens) { fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]); - // Reuse the text engine's script-aware system fallback (#84), without embedding a CJK font. + install_cjk_fallbacks(&mut fonts, language); + ctx.set_fonts(fonts); + apply_visuals(ctx, t); +} + +/// Register the CJK system fallbacks for `language` in every font family, and return their names in +/// registration order (empty when nothing installed covers a probe, so a machine without CJK fonts +/// simply keeps the bundled ones). +/// +/// The text engine has its own script-aware fallback (#84), but which face it picks for Han +/// ideographs depends on the locale environment: a Chinese interface on a Japanese-locale machine +/// would be drawn with the Japanese face. The interface language is the better signal, so the probe +/// for its script comes first — kana for Japanese, a Han character for the Chinese catalogs. Both +/// scripts are registered, so file, layer and template names in the other one still render. The web +/// build has no system fonts and installs nothing. +fn install_cjk_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec { + #[cfg(target_arch = "wasm32")] + { + let _ = (family_fonts, language); + Vec::new() + } #[cfg(not(target_arch = "wasm32"))] { use effectcraft_text::fonts; + // Kana is unambiguously Japanese; Han ideographs are shared, so the order decides which + // language's face draws them. + const KANA: (char, &'static str) = ('あ', "japanese-system"); + const HAN: (char, &'static str) = ('文', "chinese-system"); + // Han text follows the interface language, not the operating system's locale. + fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; - let face = fonts::face(fonts::fallback_for('あ', base)); - if face.has_char('あ') - && let Some(font) = face.font() - { + let probes = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] }; + let mut names = Vec::new(); + let mut seen = Vec::new(); + for (probe, name) in probes { + let id = fonts::fallback_for(probe, base); + if seen.contains(&id) { + continue; + } + let face = fonts::face(id); + if !face.has_char(probe) { + continue; // nothing installed covers this script + } + let Some(font) = face.font() else { continue }; + seen.push(id); // Use the already-read, parsed bytes rather than reading a font file twice. let mut data = FontData::from_owned(font.data().as_bytes().to_vec()); data.index = face.info.index; - fonts.font_data.insert("japanese-system".into(), Arc::new(data)); - for family in fonts.families.values_mut() { - family.push("japanese-system".into()); + family_fonts.font_data.insert(name.to_string(), Arc::new(data)); + for family in family_fonts.families.values_mut() { + family.push(name.to_string()); } + names.push(name.to_string()); } + names } - ctx.set_fonts(fonts); - apply_visuals(ctx, t); +} + +/// The CJK locale the text engine should prefer for Han text: the interface language when it names +/// one, otherwise the empty string, which leaves the operating system's locale in charge. +#[cfg(not(target_arch = "wasm32"))] +fn cjk_locale(language: &str) -> &str { + match language { + "ja" | "zh-hans" | "zh-hant" => language, + _ => "", + } +} + +/// Whether `language` is one of the Chinese catalogs. +#[cfg(not(target_arch = "wasm32"))] +fn is_chinese(language: &str) -> bool { + matches!(language, "zh-hans" | "zh-hant") } pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { @@ -397,3 +452,60 @@ mod japanese_font_tests { }); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The last `n` font names of `family`, in order. + fn tail(family: &[String], n: usize) -> Vec<&str> { + family.iter().rev().take(n).rev().map(String::as_str).collect() + } + + #[test] + fn cjk_fallbacks_follow_the_interface_language() { + // A Chinese interface asks for the Han probe first, so Han text is drawn by a Chinese face + // even when the operating system's locale is not Chinese. + assert_eq!(cjk_locale("zh-hans"), "zh-hans"); + assert_eq!(cjk_locale("zh-hant"), "zh-hant"); + assert_eq!(cjk_locale("ja"), "ja"); + assert_eq!(cjk_locale("en"), ""); + assert_eq!(cjk_locale("system"), ""); + let mut chinese = FontDefinitions::default(); + let installed = install_cjk_fallbacks(&mut chinese, "zh-hans"); + if installed.is_empty() { + eprintln!("no CJK font is installed: only the language plumbing can be checked here"); + } else { + assert_eq!(installed.first().map(String::as_str), Some("chinese-system"), "the Han probe comes first"); + assert!(chinese.font_data.contains_key("chinese-system")); + let expected: Vec<&str> = installed.iter().map(String::as_str).collect(); + for (family, stack) in &chinese.families { + assert_eq!(tail(stack, installed.len()), expected, "{family:?}"); + } + } + // A Japanese interface keeps the kana probe first, as it was before the language existed. + let mut japanese = FontDefinitions::default(); + let installed = install_cjk_fallbacks(&mut japanese, "ja"); + if let Some(first) = installed.first() { + assert_eq!(first, "japanese-system", "the kana probe comes first"); + } + // And an English interface leaves the operating system's locale in charge. + let mut english = FontDefinitions::default(); + install_cjk_fallbacks(&mut english, "en"); + } + + #[test] + fn a_cjk_face_is_registered_at_most_once() { + let mut fonts = FontDefinitions::default(); + let installed = install_cjk_fallbacks(&mut fonts, "zh-hant"); + let mut unique = installed.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique, installed, "no face may be registered twice"); + for name in &installed { + assert!(fonts.font_data.contains_key(name), "{name} is missing its font data"); + } + // Leave the operating system's locale behind for whatever runs next. + install_cjk_fallbacks(&mut fonts, "system"); + } +}