From 269934caca4e8a81200341e1512d963d1f4c2e21 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:19:50 -0400 Subject: [PATCH] LR-BEHAV-LOCALIZE: reinstall fonts on a language switch; restore the painted-UI tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the CJK fallback order follows the UI language's script, but the fonts were only installed on the first frame: switching 日本語 ↔ 简体中文 at runtime kept the previous language's Han forms. The app remembers the language its fonts were built for and reinstalls them when it changes. - the native menu bar rebuilds on any language change (keyed on the language code, not on "uses CJK fonts", which is true for both CJK languages) - restore the tests the language-table change dropped, for every language: core catalog entries, user-named menu items stay untranslated, the window paints each language and menu ids don't change with it, CJK glyphs in every UI family, and the UI runs without craft-fonts Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/native_menu.rs | 2 +- crates/ui-egui/src/i18n.rs | 156 +++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 10 ++ xtask/src/parity.rs | 3 +- 4 files changed, 162 insertions(+), 9 deletions(-) diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 33b5c8676..d3c9bdb7e 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec)]) -> String { out.push(';'); } } - let mut s = format!("{};", lightcraft_ui_egui::i18n::uses_cjk_fonts()); + let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code()); for (t, items) in bar { s.push_str(t); walk(items, &mut s); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5a7e0f304..c208ecb6e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -153,12 +153,6 @@ pub fn language() -> Locale { LOCALE.with(std::cell::Cell::get) } -/// Whether the active language needs the craft-fonts CJK faces (Inter alone has no CJK glyphs): -/// the native menu bar uses it to pick a text style. -pub fn uses_cjk_fonts() -> bool { - language().script() != "Latn" -} - /// Translate a built-in display label, preserving unknown labels verbatim. /// Never call this on editable user text, filenames or command identifiers. pub fn tr(source: &str) -> &str { @@ -366,7 +360,8 @@ mod tests { /// The language menu covers every language, and a language's own command selects it. #[test] fn language_commands_cover_every_language() { - let commands = [("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; + let commands = + [("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); for (id, language) in commands { @@ -389,4 +384,151 @@ mod tests { let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); assert_eq!(restored.language, Locale::ZhHans); } + + #[test] + fn catalogs_contain_core_workflows() { + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { + let value = language.tr(key); + assert!(!value.is_empty() && value != key, "{language:?}: {key}"); + } + } + } + + /// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. + #[test] + fn menu_labels_translate_but_user_names_survive() { + for language in Locale::ALL { + set_language(*language); + assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); + } + set_language(Locale::ZhHans); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色"); + set_language(Locale::Ja); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); + set_language(Locale::En); + } + + /// The text the whole window paints over a few frames in `language`. + fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { + fn collect(shape: &egui::epaint::Shape, text: &mut String) { + match shape { + egui::epaint::Shape::Text(shape) => { + text.push_str(&shape.galley.job.text); + text.push('\n'); + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + app.ui.language = language; + let mut text = String::new(); + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + // This inspects shapes without a renderer; discard texture uploads explicitly. + out.textures_delta.clear(); + text.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut text); + } + } + text + } + + /// Every language is painted by the real window, switching at runtime reinstalls the fonts + /// (their CJK fallback order follows the language), and command ids never change with it. + #[test] + fn every_language_is_painted_and_menu_ids_stay_the_same() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let english_ids = ids(&app); + for language in Locale::ALL { + let text = painted_text(&ctx, &mut app, *language); + for label in ["My Photos", "All Photos"] { + assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); + } + assert_eq!(app.font_language, *language, "the fonts follow the language"); + assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); + } + let text = painted_text(&ctx, &mut app, Locale::Ja); + assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}"); + let text = painted_text(&ctx, &mut app, Locale::ZhHans); + assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}"); + set_language(Locale::En); + } + + /// The UI families, after one frame so the font definitions are loaded. + fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(craft)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx + } + + /// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI + /// family, Monospace included. + #[test] + fn craft_fonts_render_cjk_in_the_ui() { + let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字")]; + for (language, sample) in samples { + if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.covers(language.script())) { + eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + set_language(language); + let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); + ctx.fonts_mut(|fonts| { + for family in + [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] + { + let font = egui::FontId::new(13.0, family); + for ch in sample.chars() { + assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); + } + let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); + let wide = 13.0 * (sample.chars().count() as f32 - 1.0); + assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); + } + }); + } + set_language(Locale::En); + } + + /// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; + /// Latin text keeps Inter. + #[test] + fn the_ui_works_without_craft_fonts() { + let ctx = fonts_ctx(&[]); + ctx.fonts_mut(|fonts| { + // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's + // replacement-glyph face, which there is Hack, the face that draws Latin.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); + } + }); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + for language in Locale::ALL { + app.ui.language = *language; + for frame in 0..3 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + } + set_language(Locale::En); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90b72967d..5fb0bd7b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -157,6 +157,9 @@ pub struct LightcraftApp { /// Clear `synthetic_mods` on the next frame. synthetic_mods_release: bool, styled: bool, + /// The language the installed fonts were built for: the CJK fallback order follows the UI + /// language's script, so switching language reinstalls them. + font_language: i18n::Locale, fonts_ready: bool, last_time: f64, /// Rect of the photo canvas and the displayed image (screen points) from the last frame. @@ -223,6 +226,7 @@ impl LightcraftApp { synthetic_mods: egui::Modifiers::NONE, synthetic_mods_release: false, styled: false, + font_language: i18n::Locale::En, fonts_ready: false, last_time: 0.0, canvas_rect: None, @@ -502,6 +506,12 @@ impl LightcraftApp { let repaint = ctx.clone(); self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); self.styled = true; + self.font_language = self.ui.language; + } else if self.font_language != self.ui.language { + // Shared Han characters take the active language's forms (Japanese faces for 日本語, + // the Simplified Chinese face for 简体中文): rebuild the fallback order. + theme::install_fonts(ctx); + self.font_language = self.ui.language; } else { self.fonts_ready = true; } diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 321e3413c..fc87ecc38 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -442,7 +442,8 @@ old fn reads_ui_command_ids() { let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }"; assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]); - let languages = "pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n"; + let languages = + "pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n"; assert_eq!(ui_command_ids(&format!("{languages}{src}")), ["view.detail", "app.about", "app.language.english"]); }