LR-BEHAV-LOCALIZE: reinstall fonts on a language switch; restore the painted-UI tests

- 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-07 06:19:50 -04:00
co-authored by Claude Opus 5.5
parent 87a58b4183
commit 269934caca
4 changed files with 162 additions and 9 deletions
+1 -1
View File
@@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> 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);
+149 -7
View File
@@ -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::<Vec<_>>();
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);
}
}
+10
View File
@@ -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;
}
+2 -1
View File
@@ -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"]);
}