The localization layer was hardcoded to English and Japanese: an enum with two
variants, an `is_japanese()` gate compiled into the format macro, and one
catalog pair. Adding a language meant editing the menus, the settings row, the
checkmark logic, the format macro and the font stack — each in a different place.
A language is now one entry in `i18n::language_table!`: BCP-47 code, endonym,
ISO 15924 script and catalog. The Language menu, the settings row, the shortcut
sheet and the control channel are all built from that table, and `build.rs`
picks up `locales/<code>-formats.json` by name, so a further language is a table
entry plus two catalogs (docs/localization.md).
Simplified Chinese (`zh-hans`) ships as the second translation: `zh-hans.json`
(1019 entries, key-set and placeholder-identical to `ja.json`) and
`zh-hans-formats.json` (102 entries). Its glyphs come from craft-fonts' Noto
Sans CJK SC, whose `Hans` faces the UI now installs *before* the Japanese ones
for a Chinese reader, so shared Han characters keep the language's forms
(`fonts::cjk_fallback`); the export watermark accepts every CJK face.
`tr_format!` keeps its compile-time check. It formats the English source at the
call site — a runtime template cannot be handed to `format!` — and names those
values so a translation can place them by name (`{n}`) or position (`{_1}`).
Every translation is still checked by `format!`, so a translation with an
unknown placeholder fails the build. The Japanese catalog was rewritten from the
old `{:.0}` plural-discard trick to that by-name form; behaviour is unchanged.
Also: settings persist the BCP-47 code (never the Rust variant name),
`LIGHTCRAFT_LANGUAGE` accepts system-style tags (`zh_CN`, `ja_JP.UTF-8`),
`xtask parity` reads the second command table, and the glyph tests cover every
catalog character plus the interface symbols (`▸`/`▾` turned out to have no
glyph in any installed font and are gone from the Chinese text).
Chinese and Japanese both need `CRAFT_FONTS_DIR` as before; the web build still
embeds only the Japanese face (Noto Sans CJK SC alone would push the wasm past
Cloudflare's 25 MiB per-file cap), documented in docs/localization-zh-hans.md.
Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
4.2 KiB
Localization
The interface ships in the language it is written in (English) plus every language in the table in
crates/ui-egui/src/i18n.rs. Today that is English, Simplified Chinese (zh-hans) and Japanese
(ja). This file is the reference for adding or maintaining a language; the per-language notes
are in localization-zh-hans.md and localization-ja.md.
Adding a language
-
Translate the two catalogs. Copy
locales/ja.jsonandlocales/ja-formats.jsontolocales/<code>.jsonandlocales/<code>-formats.json, then translate the values. Keys are the English source strings and must stay byte-for-byte identical —cargo test -p lightcraft-ui-egui i18n::testsfails on a missing, extra or empty entry. -
Add the table entry in
crates/ui-egui/src/i18n.rs:language_table! { En, "en"; ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); }The fields are the BCP-47 code (also the settings-file value), the endonym shown in the Language menu, the ISO 15924 script (
Latn,Jpan,Hans,Hant,Kore…), and the embedded catalog.build.rspicks up<code>-formats.jsonby name, so the macro and the format lookup follow automatically — nothing else in the UI needs to change. The menus, the settings row, the keyboard-shortcut sheet and the control channel all read the table. -
Make sure the glyphs exist. The script drives which craft-fonts faces the UI installs: a language whose script has no face in the build shows boxes (the test says so instead of failing). Fonts live in storytold/craft-fonts, never here; a missing face is added there, not worked around in this repository.
-
Run
cargo xtask ci(it includesfmt,clippy, the tests,parityand the wasm check).
How a message is found
tr("Exposure") looks the English label up in the active language's catalog and returns the source
text unchanged when there is no entry; tr_format! handles text with runtime values. Both are
presentation-only: command ids, file names and user-entered metadata are never translated.
tr_format! is generated by crates/ui-egui/build.rs. It formats the English source at the call
site, which is what gives format! a literal to check against that call site's own arguments, and
it also names those values so a translation can place them:
tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n)
- A translation refers to a value by name exactly as the English source spells it (
{n}), or by position as_1,_2, … in call-site order. Reordering is fine:"已将 {n} 张照片添加到“{_2}”". - The English plural suffix (
"s"/"") is simply not referenced in languages without plurals. - Every translation is placeholder-checked at compile time: an unknown name, or a spec that does not
match its value, fails the build rather than printing
{}at runtime. - A source string with no translation falls back to the English text, values included.
Testing and looking at it
cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage
The glyph tests are skipped without CRAFT_FONTS_DIR (there are no CJK faces to check then).
To look at a language, render it headless:
LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000
or, with the app running (--control 7980), run the language command
(engine.execute {"command": "app.language.zh-hans"}) and take a screenshot.
Language codes and settings
The settings file stores the BCP-47 code, never the Rust variant name, so a language can be renamed
in code without invalidating anyone's saved preference. LIGHTCRAFT_LANGUAGE accepts what a system
locale looks like (zh_Hans, zh-CN, en_US, ja_JP.UTF-8) and falls back to the base language
when a region has no dedicated entry.