Merge pull request #184 from lzjtugjc/i18n/zh-hans
LR-BEHAV-LOCALIZE: a language table, Simplified Chinese, and per-script font fallback
This commit is contained in:
commit
f0bb7c768e
|
|
@ -334,14 +334,14 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light
|
|||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
**Japanese text** needs the shared font repo, an optional build input (official releases always include it):
|
||||
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
```
|
||||
|
||||
Without it LightCraft builds and runs the same, but Japanese text has no glyphs. Fonts are never committed to this
|
||||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
|
|
@ -432,7 +432,7 @@ Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel
|
|||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases)
|
||||
also embed its Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
|
||||
also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
|
|
|
|||
16
ROADMAP.md
16
ROADMAP.md
|
|
@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Chinese/Japanese/English UI (see docs/localization.md); remaining technical errors and date formats; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
|||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Chinese/Japanese/English localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
|
||||
|
||||
|
|
@ -192,12 +192,14 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
|||
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
|
||||
tracker rows for camera colour, camera coverage and render fidelity.
|
||||
|
||||
## Japanese interface and text watermarks
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
English/Japanese interface language is persisted in UI state. Core menus have Japanese
|
||||
translations; untranslated panels and dialogs retain English. Japanese glyphs (UI: BIZ UDPGothic;
|
||||
watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made with the
|
||||
optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
|
||||
English, Simplified Chinese and Japanese interface languages are persisted in UI state, and the
|
||||
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
|
||||
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
|
||||
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
|
||||
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
|
||||
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
|
||||
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
|
||||
characters stay upright in top-to-bottom columns, with newlines starting columns to the
|
||||
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
|||
out.push(';');
|
||||
}
|
||||
}
|
||||
let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese());
|
||||
let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code());
|
||||
for (t, items) in bar {
|
||||
s.push_str(t);
|
||||
walk(items, &mut s);
|
||||
|
|
|
|||
|
|
@ -245,14 +245,15 @@ impl Default for Watermark {
|
|||
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
|
||||
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
|
||||
|
||||
/// The watermark faces: Inter first, then the craft-fonts Japanese faces (Mincho first, the
|
||||
/// watermark's serif look; then any other Japanese face). Without craft-fonts that is Inter alone,
|
||||
/// and Japanese characters draw as Inter's missing-glyph box.
|
||||
/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
|
||||
/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is
|
||||
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
|
||||
/// missing-glyph box.
|
||||
fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::FontRef<'static>> {
|
||||
let mut japanese: Vec<_> = crate::fonts::japanese(craft).collect();
|
||||
japanese.sort_by_key(|f| !f.is_mincho());
|
||||
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
|
||||
cjk.sort_by_key(|f| !f.is_mincho());
|
||||
std::iter::once(WATERMARK_FONT)
|
||||
.chain(japanese.into_iter().map(|f| f.bytes))
|
||||
.chain(cjk.into_iter().map(|f| f.bytes))
|
||||
.filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok())
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! Fonts from the optional craft-fonts build input (storytold/craft-fonts).
|
||||
//!
|
||||
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
|
||||
//! manifest (today: the Japanese UI and document fonts); otherwise it is empty and LightCraft uses
|
||||
//! only its own bundled fonts (Inter). Both the egui UI and the export watermark renderer read it
|
||||
//! from here. See craftrules `standards/fonts.md`.
|
||||
//! manifest (today: the Japanese and Simplified-Chinese UI and document fonts); otherwise it is
|
||||
//! empty and LightCraft uses only its own bundled fonts (Inter). Both the egui UI and the export
|
||||
//! watermark renderer read it from here. See craftrules `standards/fonts.md`.
|
||||
|
||||
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
|
||||
pub struct CraftFont {
|
||||
|
|
@ -33,6 +33,43 @@ pub fn japanese(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static Cr
|
|||
fonts.iter().filter(|f| f.covers("Jpan"))
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for any CJK script: what any CJK text needs, regardless of language.
|
||||
pub fn cjk(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static CraftFont> {
|
||||
fonts.iter().filter(|f| f.scripts.iter().any(|script| is_cjk_script(script)))
|
||||
}
|
||||
|
||||
/// Whether an ISO 15924 script is written with Han, kana or hangul characters (Unicode's CJK
|
||||
/// scripts), which Inter has no glyphs for.
|
||||
pub fn is_cjk_script(script: &str) -> bool {
|
||||
matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang")
|
||||
}
|
||||
|
||||
/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script
|
||||
/// first (for `style` when asked), then every other CJK face. One CJK face list serves every
|
||||
/// language, but Han characters are shared between them: a Chinese reader must get the Chinese
|
||||
/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first.
|
||||
/// Empty without craft-fonts.
|
||||
pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
|
||||
let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect();
|
||||
faces.sort_by_key(|font| {
|
||||
(
|
||||
// The language's own script first. A face dedicated to the script is preferred over a
|
||||
// multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`).
|
||||
!font.covers(script),
|
||||
font.scripts.len() != 1,
|
||||
font.family != "BIZ UDPGothic",
|
||||
font.style != style,
|
||||
font.is_mincho(),
|
||||
)
|
||||
});
|
||||
faces
|
||||
}
|
||||
|
||||
/// Whether the font is for any CJK script.
|
||||
fn covers_cjk(font: &CraftFont) -> bool {
|
||||
font.scripts.iter().any(|script| is_cjk_script(script))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -49,4 +86,21 @@ mod tests {
|
|||
}
|
||||
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
|
||||
}
|
||||
|
||||
/// Each language's own faces come first, so shared Han keeps the language's forms, and Latin
|
||||
/// text still reaches Inter before any CJK fallback.
|
||||
#[test]
|
||||
fn a_language_gets_its_own_script_first() {
|
||||
static FACES: &[CraftFont] = &[
|
||||
CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
|
||||
CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] },
|
||||
CraftFont { family: "BIZ UDMincho", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
|
||||
];
|
||||
let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family);
|
||||
assert_eq!(first("Hans"), Some("Noto Sans CJK SC"));
|
||||
assert_eq!(first("Jpan"), Some("BIZ UDPGothic"));
|
||||
// Every CJK face is still offered to every language.
|
||||
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
|
||||
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,100 @@
|
|||
//! Compile translated format strings so Rust checks both languages' placeholders.
|
||||
use std::{collections::BTreeMap, env, fs, path::PathBuf};
|
||||
//! Compile the translated format strings into the `tr_format!` macro.
|
||||
//!
|
||||
//! `locales/<code>-formats.json` maps an English format string to its translation for one language.
|
||||
//! Every English message becomes one `tr_format!` arm that picks the active language and formats
|
||||
//! that language's translation with `format!` — so Rust checks every catalog's placeholders against
|
||||
//! the call site's arguments (a wrong or forgotten placeholder is a compile error, and `{:.1}`,
|
||||
//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means
|
||||
//! adding its file (and its `language_table!` entry); this script picks the file up by name.
|
||||
//!
|
||||
//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog
|
||||
//! does not compile, so a message missing from one language would otherwise go unnoticed until it
|
||||
//! shows English.
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
env, fs,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed=locales/ja-formats.json");
|
||||
let messages: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?;
|
||||
let mut source = String::from("macro_rules! tr_format {\n");
|
||||
for (english, japanese) in messages {
|
||||
let en = serde_json::to_string(&english)?;
|
||||
let ja = serde_json::to_string(&japanese)?;
|
||||
source.push_str(&format!(
|
||||
"({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n"
|
||||
));
|
||||
}
|
||||
source.push_str("}\npub(crate) use tr_format;\n");
|
||||
fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?;
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
// The directory itself, so a catalog added or removed reruns this script.
|
||||
println!("cargo:rerun-if-changed=locales");
|
||||
let out = PathBuf::from(env::var("OUT_DIR")?);
|
||||
fs::write(out.join("tr-formats.rs"), generate()?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `ja` → `Ja`, `zh-hans` → `ZhHans`: the `Locale` variant the `language_table!` entry declares.
|
||||
/// The code must be a lowercase BCP-47-like tag (`^[a-z]+(-[a-z]+)*$`), so the variant is always a
|
||||
/// valid Rust identifier.
|
||||
fn variant_for(code: &str) -> Result<String, String> {
|
||||
let valid = !code.is_empty() && code.split('-').all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase()));
|
||||
if !valid {
|
||||
return Err(format!("locales/{code}-formats.json: the language code must match ^[a-z]+(-[a-z]+)*$"));
|
||||
}
|
||||
Ok(code.split('-').map(capitalize).collect())
|
||||
}
|
||||
|
||||
fn capitalize(part: &str) -> String {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut catalogs: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
|
||||
for entry in fs::read_dir("locales")? {
|
||||
let path = entry?.path();
|
||||
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string();
|
||||
// `en` is the source language: it has no catalog, its formats are the source themselves.
|
||||
let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue };
|
||||
println!("cargo:rerun-if-changed=locales/{name}");
|
||||
let variant = variant_for(code)?;
|
||||
let messages: BTreeMap<String, String> =
|
||||
serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?;
|
||||
catalogs.push((code.to_string(), variant, messages));
|
||||
}
|
||||
catalogs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// Every catalog translates the same messages.
|
||||
let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect();
|
||||
let mut problems = Vec::new();
|
||||
for (code, _, messages) in &catalogs {
|
||||
for key in &all {
|
||||
if !messages.contains_key(*key) {
|
||||
problems.push(format!("locales/{code}-formats.json is missing {key:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !problems.is_empty() {
|
||||
return Err(problems.join("\n").into());
|
||||
}
|
||||
|
||||
let mut source = String::from(concat!(
|
||||
"/// Translate a format string, checked by `format!` in every language.\n",
|
||||
"/// `build.rs` writes one arm per message from `locales/*-formats.json`.\n",
|
||||
"macro_rules! tr_format {\n",
|
||||
));
|
||||
for english in all {
|
||||
let en = serde_json::to_string(english)?;
|
||||
source.push_str(&format!(" ({en} $(, $($args:tt)*)?) => {{\n match $crate::i18n::language() {{\n"));
|
||||
for (_, variant, messages) in &catalogs {
|
||||
if let Some(translated) = messages.get(english) {
|
||||
let translated = serde_json::to_string(translated)?;
|
||||
source.push_str(&format!(" $crate::i18n::Locale::{variant} => format!({translated} $(, $($args)*)?),\n"));
|
||||
}
|
||||
}
|
||||
source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n"));
|
||||
}
|
||||
source.push_str(concat!(
|
||||
"}\n",
|
||||
"\n",
|
||||
"// `macro_rules!` is textual: the module re-export is what lets call sites say\n",
|
||||
"// `crate::i18n::tr_format!`.\n",
|
||||
"pub(crate) use tr_format;\n",
|
||||
));
|
||||
Ok(source)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
{
|
||||
" · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)",
|
||||
" · {} duplicate{} skipped": " · 已跳过 {0} 个重复项{1:.0}",
|
||||
" · {} failed: {}": " · {0} 个失败:{1}",
|
||||
" · {} moved": " · 已移动 {0} 个",
|
||||
" · {} not readable": " · {0} 个无法读取",
|
||||
", {failed} file{} not readable": ",{failed} 个文件无法读取{0:.0}",
|
||||
"Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片",
|
||||
"Add {} Photo{}": "添加 {0} 张照片{1:.0}",
|
||||
"Add “{}” to Favorites": "将“{0}”添加到收藏夹",
|
||||
"Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}",
|
||||
"Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}",
|
||||
"All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。",
|
||||
"Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片",
|
||||
"Automatic ({} MB)": "自动({0} MB)",
|
||||
"Click {}:1": "单击 {0}:1",
|
||||
"Create {n} Virtual Copies": "创建 {n} 个虚拟副本",
|
||||
"Decoder: {reason}": "解码器:{reason}",
|
||||
"Delete \"{label}\"": "删除 \"{label}\"",
|
||||
"Delete \"{label}\" (and the mask)": "删除 \"{label}\"(及蒙版)",
|
||||
"Delete {n} Photos": "删除 {n} 张照片",
|
||||
"Delete “{current}”": "删除“{current}”",
|
||||
"Duplicate \"{label}\"": "复制 \"{label}\"",
|
||||
"Example: {example}": "示例:{example}",
|
||||
"Exported {n} photo{}": "已导出 {n} 张照片{0:.0}",
|
||||
"Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}",
|
||||
"Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}",
|
||||
"Exported {} preset{}": "已导出 {0} 个预设{1:.0}",
|
||||
"Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张",
|
||||
"Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选{0:.0}",
|
||||
"Healed {n} dust spot{}": "已修复 {n} 处尘点{0:.0}",
|
||||
"Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设{0:.0}",
|
||||
"Imported {n} preset{}": "已导入 {n} 个预设{0:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}",
|
||||
"Imported {} photo{}": "已导入 {0} 张照片{1:.0}",
|
||||
"Matches: {}": "匹配项:{0}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%",
|
||||
"Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",
|
||||
"Named ({named_n})": "已命名({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}",
|
||||
"Preset: {name}": "预设:{name}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({0})",
|
||||
"Preview only — {}": "仅预览 — {0}",
|
||||
"Profile: {}": "配置文件:{0}",
|
||||
"Proof Copy ({})": "校样副本({0})",
|
||||
"Proof Preview · {}": "校样预览 · {0}",
|
||||
"Quick Develop · {n} photos": "快速编辑 · {n} 张照片",
|
||||
"Rated {}": "已评级 {0}",
|
||||
"Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张",
|
||||
"Redo {}": "重做:{0}",
|
||||
"Remove “{}” from Favorites": "从收藏夹中移除“{0}”",
|
||||
"Rename {n} Photos…": "重命名 {n} 张照片…",
|
||||
"Rename “{name}”": "重命名“{name}”",
|
||||
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
|
||||
"Reset {} Channel": "重置 {0} 通道",
|
||||
"Saved export preset “{}”": "已保存导出预设“{0}”",
|
||||
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
|
||||
"Showing {n} of {}": "显示 {0} 张中的 {n} 张",
|
||||
"Smart album: {}": "智能相册:{0}",
|
||||
"Undo {}": "撤销 {0}",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。",
|
||||
"Version: {}": "版本:{0}",
|
||||
"none (CPU rendering): {why}": "无(CPU 渲染):{why}",
|
||||
"the {n} selected photos": "选中的 {n} 张照片",
|
||||
"the {} photos in view": "视图中的 {0} 张照片",
|
||||
"these {count} photos": "这 {count} 张照片",
|
||||
"{count} smart preview{} · {:.1} MB": "{count} 个智能预览{0:.0} · {1:.1} MB",
|
||||
"{f} folders (and their subfolders)": "{f} 个文件夹(及其子文件夹)",
|
||||
"{label} Grading": "{label} 颜色分级",
|
||||
"{label} {d:+} on every selected photo": "每张选中照片的 {label} {d:+}",
|
||||
"{label}, {n} photos": "{label},{n} 张照片",
|
||||
"{matching} of {t} photos": "{t} 张照片中的 {matching} 张",
|
||||
"{matching} photos": "{matching} 张照片",
|
||||
"{mode} spot {} of {}": "{mode}:第 {0} 个污点,共 {1} 个",
|
||||
"{msg} ({failed} failed)": "{msg}({failed} 项失败)",
|
||||
"{name} Label": "{name} 标签",
|
||||
"{name} Off": "已关闭 {name}",
|
||||
"{name} On": "已开启 {name}",
|
||||
"{name} — double-click to reset it": "{name} — 双击可重置",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。{0:.0}{1:.0}",
|
||||
"{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张",
|
||||
"{n} photos": "{n} 张照片",
|
||||
"{n} photos in {} are hidden by the filters above": "{0} 中的 {n} 张照片被上方的过滤器隐藏",
|
||||
"{n} photo{}": "{n} 张照片{0:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}",
|
||||
"{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}",
|
||||
"{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {0:.0}%",
|
||||
"{what} merge added": "已添加 {what} 合并",
|
||||
"{what} merge failed: {e}": "{what} 合并失败:{e}",
|
||||
"{} and higher": "{0} 及以上",
|
||||
"{} files": "{0} 个文件",
|
||||
"{} files and {f} folder{}": "{0} 个文件和 {f} 个文件夹{1:.0}",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。",
|
||||
"{} photos": "{0} 张照片",
|
||||
"{} photo{} not readable": "{0} 张照片无法读取{1:.0}",
|
||||
"{} spot(s) on this photo": "此照片上有 {0} 处污点",
|
||||
"· {dups} already in the library": "· {dups} 张已在图库中",
|
||||
"· {n} photo{}": "· {n} 张照片{0:.0}",
|
||||
"… and {more} more": "…以及另外 {more} 项",
|
||||
"Import cancelled · {} photo{} added": "导入已取消 · 已添加 {0} 张照片{1:.0}",
|
||||
"Reading photos… {} of {}": "正在读取照片… 第 {0} 张,共 {1} 张",
|
||||
"Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张",
|
||||
" ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)",
|
||||
"Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -55,7 +55,7 @@ fn wrap(r: Result<Value, String>) -> Outcome {
|
|||
|
||||
pub fn all_commands(app: &LightcraftApp) -> Value {
|
||||
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
for (id, label, sc, menu) in crate::menus::UI_COMMANDS {
|
||||
for (id, label, sc, menu) in crate::menus::ui_commands() {
|
||||
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
|
||||
}
|
||||
Value::Array(v)
|
||||
|
|
|
|||
|
|
@ -1,123 +1,418 @@
|
|||
//! Presentation-only localization. Command ids, user names and catalog data remain unchanged.
|
||||
use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
|
||||
//!
|
||||
//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the
|
||||
//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its
|
||||
//! message catalog (`locales/<code>.json`, embedded at build time). Adding a language is that entry
|
||||
//! plus its catalogs — every menu and settings surface is built from the table, so nothing else in
|
||||
//! the UI changes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock};
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/ja-formats.rs"));
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
#[serde(rename = "en")]
|
||||
En,
|
||||
#[serde(rename = "ja")]
|
||||
Ja,
|
||||
/// The language table.
|
||||
///
|
||||
/// The source language comes first and alone: it is the text the UI is authored in, which every
|
||||
/// other language translates. `name` is written in the language itself (an endonym, so a user who
|
||||
/// cannot read the current UI language can still find their own).
|
||||
macro_rules! language_table {
|
||||
($source:ident, $source_code:literal; $( $variant:ident, $code:literal, $name:literal, $script:literal, $catalog:expr );* $(;)?) => {
|
||||
/// A UI language.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Locale {
|
||||
/// The language the UI is written in.
|
||||
#[default]
|
||||
$source,
|
||||
$($variant,)*
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
/// Every language, in menu order (the source language first).
|
||||
pub const ALL: &'static [Self] = &[Self::$source, $(Self::$variant,)*];
|
||||
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self { Self::$source => $source_code, $(Self::$variant => $code,)* }
|
||||
}
|
||||
|
||||
/// The language's own name, as shown in the Language menus.
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self { Self::$source => "English", $(Self::$variant => $name,)* }
|
||||
}
|
||||
|
||||
/// The ISO 15924 script this language is written in (`"Latn"`, `"Jpan"`, `"Hans"`…):
|
||||
/// the craft-fonts faces for it are the ones this language needs.
|
||||
pub const fn script(self) -> &'static str {
|
||||
match self { Self::$source => "Latn", $(Self::$variant => $script,)* }
|
||||
}
|
||||
|
||||
/// The language's embedded catalog, parsed once. The source language has none: its
|
||||
/// lookups return the source text itself.
|
||||
fn catalog(self) -> &'static BTreeMap<String, String> {
|
||||
static CATALOGS: OnceLock<BTreeMap<Locale, BTreeMap<String, String>>> = OnceLock::new();
|
||||
static EMPTY: OnceLock<BTreeMap<String, String>> = OnceLock::new();
|
||||
// A malformed catalog logs and comes back empty: a broken translation degrades to
|
||||
// English text, it never takes the app down.
|
||||
let parse = |catalog: Option<&'static str>| match catalog {
|
||||
Some(json) => match serde_json::from_str(json) {
|
||||
Ok(messages) => messages,
|
||||
Err(error) => {
|
||||
log::error!("Invalid message catalog for {}: {error}", self.code());
|
||||
BTreeMap::new()
|
||||
}
|
||||
},
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
CATALOGS
|
||||
.get_or_init(|| {
|
||||
Locale::ALL
|
||||
.iter()
|
||||
.map(|language| {
|
||||
let catalog = match *language { $(Self::$variant => Some($catalog),)* _ => None };
|
||||
(*language, parse(catalog))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(&self)
|
||||
.unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new))
|
||||
}
|
||||
|
||||
pub fn parse(code: &str) -> Option<Self> {
|
||||
match code { $source_code => Some(Self::$source), $($code => Some(Self::$variant),)* _ => None }
|
||||
}
|
||||
|
||||
/// Lenient lookup: `zh`, `zh-CN`, `zh_Hans`, `en-US`, `ja_JP.UTF-8`… all reach a
|
||||
/// shipped language. A region or script the table does not ship falls back to the
|
||||
/// language itself, so a user is never left with no language at all.
|
||||
pub fn parse_tag(tag: &str) -> Option<Self> {
|
||||
let tag = tag.split(['.', '@']).next().unwrap_or(tag).replace('_', "-");
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
if let Some(exact) = Self::ALL.iter().find(|language| language.code().eq_ignore_ascii_case(&lower)) {
|
||||
return Some(*exact);
|
||||
}
|
||||
let mut parts = lower.split('-');
|
||||
let language = parts.next()?;
|
||||
// `zh-Hans-CN`, `zh-CN` and `zh` all name the same base language.
|
||||
let script = parts.next().and_then(|part| match part {
|
||||
"hans" | "cn" | "sg" => Some("Hans"),
|
||||
"hant" | "tw" | "hk" | "mo" => Some("Hant"),
|
||||
_ => None,
|
||||
});
|
||||
Self::ALL
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
let base = candidate.code().split('-').next().unwrap_or_default();
|
||||
base == language && script.is_none_or(|script| candidate.script() == script)
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub fn tr(self, source: &str) -> &str {
|
||||
tr_in(self, source)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "English",
|
||||
Self::Ja => "日本語",
|
||||
}
|
||||
// The source language, then one entry per translation: code, endonym, ISO 15924 script, embedded
|
||||
// catalog (`locales/<code>.json`).
|
||||
language_table! {
|
||||
En, "en";
|
||||
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
|
||||
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
|
||||
}
|
||||
|
||||
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
|
||||
// language can be renamed in code without invalidating anyone's settings.
|
||||
impl Serialize for Locale {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.code())
|
||||
}
|
||||
pub fn parse(code: &str) -> Option<Self> {
|
||||
match code {
|
||||
"en" => Some(Self::En),
|
||||
"ja" => Some(Self::Ja),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn tr(self, source: &str) -> &str {
|
||||
if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Locale {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let code = String::deserialize(deserializer)?;
|
||||
Locale::parse(&code).or_else(|| Locale::parse_tag(&code)).ok_or_else(|| serde::de::Error::custom(format!("unknown language {code:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static LANGUAGE: Cell<Language> = const { Cell::new(Language::En) };
|
||||
static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) };
|
||||
}
|
||||
|
||||
pub fn default_language() -> Language {
|
||||
if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En }
|
||||
/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs.
|
||||
pub fn default_language() -> Locale {
|
||||
std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En)
|
||||
}
|
||||
|
||||
pub fn set_language(language: Language) {
|
||||
LANGUAGE.with(|value| value.set(language));
|
||||
pub fn set_language(language: Locale) {
|
||||
LOCALE.with(|value| value.set(language));
|
||||
}
|
||||
|
||||
pub fn is_japanese() -> bool {
|
||||
LANGUAGE.with(|value| value.get() == Language::Ja)
|
||||
}
|
||||
|
||||
fn japanese() -> &'static BTreeMap<String, String> {
|
||||
static MESSAGES: OnceLock<BTreeMap<String, String>> = OnceLock::new();
|
||||
MESSAGES.get_or_init(|| {
|
||||
serde_json::from_str(include_str!("../locales/ja.json")).unwrap_or_else(|error| {
|
||||
log::error!("Invalid Japanese message catalog: {error}");
|
||||
BTreeMap::new()
|
||||
})
|
||||
})
|
||||
pub fn language() -> Locale {
|
||||
LOCALE.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if is_japanese() { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
|
||||
tr_in(language(), source)
|
||||
}
|
||||
|
||||
/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed
|
||||
/// out for as long as the `'static` key it was looked up by.
|
||||
fn tr_in(language: Locale, source: &str) -> &str {
|
||||
if language.catalog().is_empty() {
|
||||
return source;
|
||||
}
|
||||
thread_local! {
|
||||
static VERBATIM: RefCell<BTreeMap<Locale, BTreeMap<String, &'static str>>> = const { RefCell::new(BTreeMap::new()) };
|
||||
}
|
||||
VERBATIM.with_borrow_mut(|cache| {
|
||||
let verbatim = cache.entry(language).or_insert_with(|| catalog_verbatim(language));
|
||||
verbatim.get(source).copied().unwrap_or(source)
|
||||
})
|
||||
}
|
||||
|
||||
/// One language's catalog keyed for lookup, with values that outlive the catalog's own borrow.
|
||||
fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> {
|
||||
language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect()
|
||||
}
|
||||
|
||||
// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from
|
||||
// `locales/*-formats.json`.
|
||||
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_is_valid_and_contains_core_workflows() {
|
||||
let messages: BTreeMap<String, String> = serde_json::from_str(include_str!("../locales/ja.json")).unwrap();
|
||||
for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] {
|
||||
assert!(messages.get(key).is_some_and(|value| !value.is_empty() && value != key), "{key}");
|
||||
fn every_language_has_a_catalog_covering_the_source() {
|
||||
assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog");
|
||||
for language in Locale::ALL {
|
||||
assert!(!language.name().is_empty(), "{language:?} has a name");
|
||||
assert!(!language.script().is_empty(), "{language:?} has a script");
|
||||
assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code");
|
||||
if *language == Locale::En {
|
||||
continue;
|
||||
}
|
||||
let messages = language.catalog();
|
||||
assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code());
|
||||
for (key, value) in messages {
|
||||
assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalogs of the translations agree: same keys, same format placeholders. A source string
|
||||
/// added to one language but forgotten in another fails here.
|
||||
#[test]
|
||||
fn language_switches_and_unknown_text_survives() {
|
||||
set_language(Language::Ja);
|
||||
assert_eq!(tr("Exposure"), "露出");
|
||||
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
|
||||
assert_eq!(tr("develop.set"), "develop.set");
|
||||
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"), "カラー");
|
||||
set_language(Language::En);
|
||||
assert_eq!(tr("Exposure"), "Exposure");
|
||||
fn catalogs_agree_on_keys_and_placeholders() {
|
||||
let fields = |text: &str| {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let Some(end) = rest[start..].find('}') else { break };
|
||||
out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string());
|
||||
rest = &rest[start + end + 1..];
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
let translated: Vec<Locale> = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect();
|
||||
let Some((first, rest)) = translated.split_first() else { return };
|
||||
for language in rest {
|
||||
let (a, b) = (first.catalog(), language.catalog());
|
||||
for (key, value) in a {
|
||||
let Some(other) = b.get(key) else {
|
||||
panic!("{language:?} is missing a translation for {key:?}");
|
||||
};
|
||||
assert_eq!(fields(key), fields(value), "{first:?}: {key:?} -> {value:?}");
|
||||
assert_eq!(fields(key), fields(other), "{language:?}: {key:?} -> {other:?}");
|
||||
}
|
||||
for key in b.keys() {
|
||||
assert!(a.contains_key(key), "{language:?} translates {key:?}, which {first:?} does not");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Locale tags are matched leniently, so a system locale reaches a shipped language.
|
||||
#[test]
|
||||
fn locale_tags_normalize() {
|
||||
assert_eq!(Locale::parse_tag("en"), Some(Locale::En));
|
||||
assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En));
|
||||
assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja));
|
||||
assert_eq!(Locale::parse_tag("de"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() {
|
||||
set_language(Language::Ja);
|
||||
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚");
|
||||
fn switching_language_translates_and_restores() {
|
||||
set_language(Locale::ZhHans);
|
||||
assert_eq!(tr("Exposure"), "曝光");
|
||||
assert_eq!(tr("Settings"), "设置");
|
||||
// Untranslated and data-like text passes through untouched.
|
||||
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
|
||||
assert_eq!(tr("develop.set"), "develop.set");
|
||||
assert_eq!(tr("A string no catalog has"), "A string no catalog has");
|
||||
set_language(Locale::Ja);
|
||||
assert_eq!(tr("Exposure"), "露出");
|
||||
set_language(Locale::En);
|
||||
assert_eq!(tr("Exposure"), "Exposure");
|
||||
}
|
||||
|
||||
/// Format strings carry their values in every language, and a language without a translation
|
||||
/// falls back to the English format rather than dropping the values.
|
||||
#[test]
|
||||
fn formats_render_in_every_language() {
|
||||
// Every language's format string is checked by `format!` against the call site's values.
|
||||
set_language(Locale::En);
|
||||
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos");
|
||||
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos");
|
||||
set_language(Locale::ZhHans);
|
||||
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片");
|
||||
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片");
|
||||
set_language(Locale::Ja);
|
||||
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました");
|
||||
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました");
|
||||
set_language(Language::En);
|
||||
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos");
|
||||
// Format specs survive translation: precision, sign, and values a language reorders.
|
||||
for language in Locale::ALL {
|
||||
set_language(*language);
|
||||
let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3);
|
||||
assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}");
|
||||
let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning");
|
||||
assert!(merging.contains("42%"), "{language:?}: {merging}");
|
||||
let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5);
|
||||
assert!(delta.contains("+5"), "{language:?}: {delta}");
|
||||
let reading = tr_format!("Reading photos… {} of {}", 3, 10);
|
||||
let (three, ten) = (reading.find('3'), reading.find("10"));
|
||||
assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}");
|
||||
}
|
||||
set_language(Locale::En);
|
||||
assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10");
|
||||
}
|
||||
|
||||
/// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation
|
||||
/// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK
|
||||
/// catalogs are skipped (the same degradation the Japanese UI has always had).
|
||||
#[test]
|
||||
fn catalog_characters_are_paintable() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
for language in Locale::ALL {
|
||||
let messages = language.catalog();
|
||||
if messages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The faces this language's script needs; a language the craft-fonts input does not
|
||||
// cover (a translation added ahead of its font) is reported, not failed.
|
||||
let mut faces: Vec<&str> =
|
||||
lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.covers(language.script())).map(|font| font.family).collect();
|
||||
faces.dedup();
|
||||
if faces.is_empty() {
|
||||
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
|
||||
continue;
|
||||
}
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
|
||||
let font = egui::FontId::new(13.0, family.clone());
|
||||
let mut missing: Vec<char> = messages
|
||||
.values()
|
||||
.flat_map(|message| message.chars())
|
||||
.filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch))
|
||||
.collect();
|
||||
missing.sort_unstable();
|
||||
missing.dedup();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} in {family:?} (faces: {faces:?}) lack glyphs: {}",
|
||||
language.code(),
|
||||
missing.iter().collect::<String>()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a
|
||||
/// translation must not carry a symbol no installed font has (egui's defaults cover the rest).
|
||||
#[test]
|
||||
fn interface_symbols_are_paintable() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
// ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07).
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] {
|
||||
let font = egui::FontId::new(13.0, egui::FontFamily::Proportional);
|
||||
assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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)];
|
||||
// One command per language, and every command reachable from the menu table.
|
||||
assert_eq!(commands.len(), Locale::ALL.len());
|
||||
for (id, language) in commands {
|
||||
assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}");
|
||||
assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu");
|
||||
}
|
||||
assert_eq!(crate::menus::language_from_command("app.language.klingon"), None);
|
||||
assert_eq!(crate::menus::language_from_command("view.detail"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_round_trip_and_old_settings_remain_readable() {
|
||||
let old: crate::state::UiState = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(old.language, Language::En);
|
||||
let settings = crate::state::UiState { language: Language::Ja, ..old };
|
||||
assert_eq!(old.language, Locale::En);
|
||||
// Settings written before the language list grew still load.
|
||||
let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap();
|
||||
assert_eq!(legacy.language, Locale::Ja);
|
||||
let settings = crate::state::UiState { language: Locale::ZhHans, ..old };
|
||||
let saved = serde_json::to_string(&settings).unwrap();
|
||||
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, Language::Ja);
|
||||
assert_eq!(restored.language, Locale::ZhHans);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_is_painted_and_both_font_weights_cover_the_catalog() {
|
||||
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.language = Language::Ja;
|
||||
app.ui.left_panel = true;
|
||||
let mut text = String::new();
|
||||
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) => {
|
||||
|
|
@ -128,41 +423,49 @@ mod tests {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
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 assertion inspects shapes without a renderer; discard texture uploads explicitly.
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
assert!(text.contains("マイフォト"), "{text}");
|
||||
assert!(text.contains("すべての写真"), "{text}");
|
||||
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
|
||||
eprintln!("skipped glyph coverage: built without CRAFT_FONTS_DIR, so there is no Japanese UI font");
|
||||
} else {
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
|
||||
let font = egui::FontId::new(13.0, family);
|
||||
for message in japanese().values() {
|
||||
for ch in message.chars().filter(|ch| !ch.is_whitespace()) {
|
||||
assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Locale affects presentation only: command ids remain the same.
|
||||
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
|
||||
let japanese_ids = ids(&app);
|
||||
set_language(Language::En);
|
||||
assert_eq!(japanese_ids, ids(&app));
|
||||
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();
|
||||
|
|
@ -172,31 +475,36 @@ mod tests {
|
|||
ctx
|
||||
}
|
||||
|
||||
fn ui_families() -> [egui::FontFamily; 3] {
|
||||
[egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace]
|
||||
}
|
||||
|
||||
/// Built with craft-fonts, Japanese renders with real glyphs (no tofu) in every UI family.
|
||||
/// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI
|
||||
/// family, Monospace included.
|
||||
#[test]
|
||||
fn craft_fonts_render_japanese_in_the_ui() {
|
||||
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
|
||||
eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese UI font");
|
||||
return;
|
||||
}
|
||||
let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS);
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for family in ui_families() {
|
||||
let font = egui::FontId::new(13.0, family);
|
||||
for ch in "日本語の文字".chars() {
|
||||
assert!(fonts.has_glyph(&font, ch), "{ch} in {font:?}");
|
||||
}
|
||||
let galley = fonts.layout_no_wrap("日本語の文字".into(), font.clone(), egui::Color32::WHITE);
|
||||
assert!(galley.size().x > 13.0 * 5.0, "{font:?}: six full-width glyphs, {:?}", galley.size());
|
||||
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 Japanese, too) still installs its fonts and runs;
|
||||
/// 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() {
|
||||
|
|
@ -210,15 +518,17 @@ mod tests {
|
|||
}
|
||||
});
|
||||
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
app.ui.language = Language::Ja;
|
||||
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();
|
||||
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(Language::En);
|
||||
set_language(Locale::En);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`.
|
||||
//!
|
||||
//! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands
|
||||
//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine.
|
||||
//! (views, panels, zoom — see [`menus::ui_commands`]) and forwards everything else to the engine.
|
||||
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry
|
||||
//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts,
|
||||
//! (engine commands with menu paths + [`crate::menus::ui_commands`]) with live labels, shortcuts,
|
||||
//! enabled and checked state.
|
||||
//!
|
||||
//! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the
|
||||
|
|
@ -284,8 +284,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
|
|||
let u = &app.ui;
|
||||
let panel = |p: RightPanel| Some(u.right == p);
|
||||
match id {
|
||||
"app.language.english" => Some(u.language == crate::i18n::Language::En),
|
||||
"app.language.japanese" => Some(u.language == crate::i18n::Language::Ja),
|
||||
// Every language's command is checked when it is the active one.
|
||||
_ if crate::menus::language_from_command(id).is_some() => Some(crate::menus::language_from_command(id) == Some(u.language)),
|
||||
"develop.autoSync" => Some(app.session.auto_sync),
|
||||
"view.photoCounts" => Some(u.show_counts),
|
||||
"view.secondWindow" => Some(u.second_window),
|
||||
|
|
|
|||
|
|
@ -10,9 +10,32 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
|
|||
/// (id, label, shortcut, menu path)
|
||||
pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str);
|
||||
|
||||
/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language
|
||||
/// added to the table shows up in the menu (and in the control channel's command list) by itself.
|
||||
pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
|
||||
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
|
||||
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
|
||||
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
|
||||
];
|
||||
|
||||
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
|
||||
/// this file, so an id listed in `docs/parity.md` is checked wherever it is declared.
|
||||
pub fn ui_commands() -> impl Iterator<Item = &'static UiCommand> {
|
||||
LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS)
|
||||
}
|
||||
|
||||
/// The language a Language-menu command selects, if the id is one. The engine and the UI both go
|
||||
/// through here, so the menu, the settings row and the control channel agree on the mapping.
|
||||
pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
|
||||
match id {
|
||||
"app.language.english" => Some(crate::i18n::Locale::En),
|
||||
"app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans),
|
||||
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("app.language.english", "English", None, "Edit>Language"),
|
||||
("app.language.japanese", "日本語", None, "Edit>Language"),
|
||||
("view.photoGrid", "Photo Grid", None, "View"),
|
||||
("view.squareGrid", "Square Grid", None, "View"),
|
||||
// G: Photo Grid ↔ Square Grid (from other views: the photo grid)
|
||||
|
|
@ -208,8 +231,8 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
|
|||
|
||||
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
|
||||
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "app.language.english" | "app.language.japanese") {
|
||||
app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En };
|
||||
if let Some(language) = language_from_command(id) {
|
||||
app.ui.language = language;
|
||||
return Some(Ok(json!(app.ui.language)));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
|
|
@ -1261,8 +1284,7 @@ pub struct MenuEntry {
|
|||
|
||||
/// The flattened menu model (UI commands + engine commands with menu paths).
|
||||
pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
||||
let mut v: Vec<MenuEntry> = UI_COMMANDS
|
||||
.iter()
|
||||
let mut v: Vec<MenuEntry> = ui_commands()
|
||||
.filter(|c| !c.3.is_empty())
|
||||
.map(|(id, label, sc, m)| MenuEntry {
|
||||
id: id.to_string(),
|
||||
|
|
|
|||
|
|
@ -733,7 +733,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
Dialog::Shortcuts => {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
|
||||
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(sc) = sc {
|
||||
ui.label(crate::i18n::tr(label));
|
||||
ui.label(*sc);
|
||||
|
|
@ -750,8 +750,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
for (sc, id, _) in crate::shortcuts::ALIASES {
|
||||
let label = crate::menus::UI_COMMANDS
|
||||
.iter()
|
||||
let label = crate::menus::ui_commands()
|
||||
.find(|c| c.0 == *id)
|
||||
.map(|c| c.1)
|
||||
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str
|
|||
|
||||
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
row(ui, t, crate::i18n::tr("Language"), |ui| {
|
||||
choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language);
|
||||
let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect();
|
||||
choices(ui, "settingsLanguage", &languages, &mut app.ui.language);
|
||||
crate::i18n::set_language(app.ui.language);
|
||||
});
|
||||
heading(ui, t, crate::i18n::tr("Library"));
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
|
||||
ctx.input(|i| {
|
||||
let mut ui_keys = Vec::new();
|
||||
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some((m, k)) = sc.and_then(parse) {
|
||||
ui_keys.push((m, k));
|
||||
if !native(sc.unwrap_or_default()) && matches(i, m, k) {
|
||||
|
|
@ -286,7 +286,7 @@ mod tests {
|
|||
assert_eq!(parse("Right").unwrap().1, Key::ArrowRight);
|
||||
assert_eq!(parse("\\").unwrap().1, Key::Backslash);
|
||||
// every declared shortcut parses
|
||||
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(sc) = sc {
|
||||
assert!(parse(sc).is_some(), "{id}: {sc}");
|
||||
}
|
||||
|
|
@ -300,7 +300,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn aliases_parse_and_target_existing_commands() {
|
||||
let ui: Vec<&str> = crate::menus::UI_COMMANDS.iter().map(|c| c.0).collect();
|
||||
let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect();
|
||||
for (sc, id, params) in ALIASES {
|
||||
assert!(parse(sc).is_some(), "{id}: {sc}");
|
||||
assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}");
|
||||
|
|
@ -315,7 +315,7 @@ mod tests {
|
|||
#[test]
|
||||
fn no_conflicting_bindings() {
|
||||
let mut ui: Vec<((Modifiers, Key), String)> = Vec::new();
|
||||
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(k) = sc.and_then(parse) {
|
||||
ui.push((k, id.to_string()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0;
|
|||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
#[serde(default = "crate::i18n::default_language")]
|
||||
pub language: crate::i18n::Language,
|
||||
pub language: crate::i18n::Locale,
|
||||
/// The Build Previews run last announced (its identity, finished?).
|
||||
#[serde(skip)]
|
||||
pub preview_build_seen: Option<(usize, bool)>,
|
||||
|
|
|
|||
|
|
@ -110,31 +110,35 @@ pub fn install_fonts(ctx: &egui::Context) {
|
|||
ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS));
|
||||
}
|
||||
|
||||
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts Japanese faces as
|
||||
/// the last fallback of every family (BIZ UDPGothic first; Bold first for the semibold family).
|
||||
/// Without craft-fonts (`craft` empty) Japanese text has no glyphs and shows as boxes.
|
||||
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts CJK faces as the last
|
||||
/// fallback of every family — the active language's own script first, so shared Han characters keep
|
||||
/// that language's forms. Without craft-fonts (`craft` empty) CJK text has no glyphs and shows as
|
||||
/// boxes.
|
||||
pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
|
||||
fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))));
|
||||
let japanese: Vec<_> = lightcraft_engine::fonts::japanese(craft).collect();
|
||||
for f in &japanese {
|
||||
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
|
||||
}
|
||||
// Craft-fonts faces in preference order for a family drawn in `style`.
|
||||
let fallback = |style: &str| {
|
||||
let mut faces = japanese.clone();
|
||||
faces.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != style, f.family.contains("Mincho")));
|
||||
faces.into_iter().map(craft_font_name).collect::<Vec<_>>()
|
||||
lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style)
|
||||
.into_iter()
|
||||
.map(craft_font_name)
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
let (regular, bold) = (fallback("Regular"), fallback("Bold"));
|
||||
for name in regular.iter().chain(bold.iter()) {
|
||||
if let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) {
|
||||
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes)));
|
||||
}
|
||||
}
|
||||
let defaults: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let mut prop = vec!["Inter".to_string()];
|
||||
prop.extend(defaults.iter().cloned());
|
||||
prop.extend(fallback("Regular"));
|
||||
prop.extend(regular);
|
||||
fonts.families.insert(FontFamily::Proportional, prop);
|
||||
let mut semi = vec!["Inter-SemiBold".to_string()];
|
||||
semi.extend(defaults);
|
||||
semi.extend(fallback("Bold"));
|
||||
semi.extend(bold);
|
||||
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular"));
|
||||
fonts
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# LightCraft 日本語表示
|
||||
|
||||
LightCraft は日本語と英語(English)の表示に対応しています。
|
||||
LightCraft は日本語・簡体字中国語・英語(English)の表示に対応しています。
|
||||
言語設定は `ui.json` の `language` に保存します。
|
||||
|
||||
- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。
|
||||
- 「編集 → 言語」または「設定 → 一般 → 言語」で切り替えます。選択は次回起動にも引き継ぎます。
|
||||
- メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。
|
||||
- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを使います。日本語フォントはこのリポジトリではなく
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) にあり、任意のビルド入力
|
||||
|
|
@ -11,12 +11,16 @@ LightCraft は日本語と英語(English)の表示に対応しています
|
|||
指定しない場合も動作しますが、日本語の文字は表示されません。
|
||||
- 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。
|
||||
- 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。
|
||||
- 中国語表示は [`localization-zh-hans.md`](localization-zh-hans.md) を参照してください。
|
||||
- 別の言語の追加方法は [`localization.md`](localization.md) を参照してください。
|
||||
|
||||
## 翻訳の保守
|
||||
|
||||
静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は
|
||||
`crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。
|
||||
可変文言の両言語はビルド時にRustのフォーマット検査を受けます。
|
||||
|
||||
可変文言の訳文はすべてビルド時にRustの `format!` 検査を受けます。値は名前(`{n}`)または
|
||||
位置(`{0}`、`{1}`)で参照し、`{:.1}` などの書式指定は英文と同じものを使います。
|
||||
英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。
|
||||
`LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# LightCraft 简体中文界面
|
||||
|
||||
LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 `ui.json` 的 `language` 字段中。
|
||||
|
||||
- 通过「编辑 → 语言」或「设置 → 一般 → 语言」切换,选择会保留到下次启动。
|
||||
- 已翻译的范围:菜单、照片编辑、蒙版、裁剪、设置、导入、导出,以及主要的进度提示。
|
||||
- 正文字重与半粗体都使用 SIL OFL 的 **Noto Sans CJK SC**(简体中文)与 BIZ UDPGothic(日语)。
|
||||
这些字体不在本仓库,而在 [storytold/craft-fonts](https://github.com/storytold/craft-fonts);
|
||||
使用可选的构建输入 `CRAFT_FONTS_DIR=../craft-fonts` 时(所有正式发布版)会内嵌进来。
|
||||
不指定该输入时程序照常构建运行,但中文与日文文字没有字形,会显示为方框。
|
||||
- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。
|
||||
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
|
||||
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
|
||||
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
|
||||
原生桌面版不受影响。
|
||||
- 日期分组标题(如 `Sunday, 20 September 2026`)沿用英文格式,尚未本地化。
|
||||
|
||||
## 翻译的维护
|
||||
|
||||
静态界面文字在 `crates/ui-egui/locales/zh-hans.json`,含运行时数值的文字在
|
||||
`crates/ui-egui/locales/zh-hans-formats.json`。两者都以英文原文为键。
|
||||
|
||||
含数值的文字遵循以下约定:
|
||||
|
||||
- 每个译文都是普通的 Rust 格式字符串,构建时由 `format!` 对照调用点的参数检查(占位符写错会编译失败)。
|
||||
- **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。
|
||||
- **按位置**:`{0}`、`{1}` … 对应调用点无名参数的先后顺序(从 0 开始),用于调整语序,
|
||||
例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{1}”{0:.0}`。
|
||||
- 保留英文原文的格式说明(`{:.1}`、`{d:+}`、`{:.0}%`)。
|
||||
- `format!` 不允许未使用的参数:英文用来表示单复数的那个参数(`"s"` / `""`)在中文里写成
|
||||
`{:.0}`(精度为 0 的字符串不输出任何内容)。
|
||||
|
||||
`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。
|
||||
|
||||
翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由
|
||||
`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# 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`](localization-zh-hans.md) and [`localization-ja.md`](localization-ja.md).
|
||||
|
||||
## Adding a language
|
||||
|
||||
1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to
|
||||
`locales/<code>.json` and `locales/<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::tests` fails on a missing, extra or empty entry.
|
||||
2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`:
|
||||
|
||||
```rust
|
||||
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.rs` picks up `<code>-formats.json` by 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.
|
||||
3. **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](https://github.com/storytold/craft-fonts), never here; a
|
||||
missing face is added there, not worked around in this repository.
|
||||
4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and 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`: one arm per English message, which formats
|
||||
the active language's translation with `format!` (English is the fallback arm). Every catalog's
|
||||
format string is therefore checked by Rust against the call site's own arguments:
|
||||
|
||||
```rust
|
||||
tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n)
|
||||
```
|
||||
|
||||
- A translation is an ordinary Rust format string over the same arguments. Refer to named values by
|
||||
name (`{n}`) and to positional ones by index when the word order changes (`{0}`, `{1}`):
|
||||
`"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`).
|
||||
- `format!` rejects an unused argument, so a value a language does not need — the English plural
|
||||
suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing).
|
||||
- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a
|
||||
`tr_format!` call whose message is in no catalog does not compile: add it to every
|
||||
`*-formats.json` file.
|
||||
|
||||
## Testing and looking at it
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
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.simplifiedChinese"}`) 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.
|
||||
|
|
@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 15 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 16 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
|
||||
| **Total** | 387 | 33 | 87 | 37 | 194/200 (97%) | 139/149 (93%) |
|
||||
| **Total** | 388 | 33 | 87 | 37 | 194/200 (97%) | 139/149 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.6%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 40.5% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.6%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 40.9% of 159.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
@ -503,7 +503,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
|
||||
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
|
||||
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md` | Japanese/English UI with persisted language preference and Japanese regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Technical errors/release notes and other locales remain untranslated. |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Technical errors/release notes and date grouping headers remain English, and the web build embeds only the Japanese face. |
|
||||
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
|
||||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
|
||||
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
|
||||
|
|
|
|||
|
|
@ -137,12 +137,17 @@ pub fn parse(md: &str) -> Doc {
|
|||
doc
|
||||
}
|
||||
|
||||
/// Ids in `UI_COMMANDS` (`("view.detail", "Detail", Some("D"), "View"),` lines).
|
||||
/// Ids in the menu tables (`("view.detail", "Detail", Some("D"), "View"),` lines): the language
|
||||
/// commands live in their own table, which the Language menu builds from the i18n language list.
|
||||
pub fn ui_command_ids(menus_rs: &str) -> Vec<String> {
|
||||
let Some(start) = menus_rs.find("pub const UI_COMMANDS") else { return Vec::new() };
|
||||
let body = &menus_rs[start..];
|
||||
let body = &body[..body.find("];").unwrap_or(body.len())];
|
||||
body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())).collect()
|
||||
let mut ids = Vec::new();
|
||||
for table in ["pub const UI_COMMANDS", "pub const LANGUAGE_COMMANDS"] {
|
||||
let Some(start) = menus_rs.find(table) else { continue };
|
||||
let body = &menus_rs[start..];
|
||||
let body = &body[..body.find("];").unwrap_or(body.len())];
|
||||
ids.extend(body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn known(set: &BTreeSet<String>, id: &str) -> bool {
|
||||
|
|
@ -437,5 +442,19 @@ 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";
|
||||
assert_eq!(ui_command_ids(&format!("{languages}{src}")), ["view.detail", "app.about", "app.language.english"]);
|
||||
}
|
||||
|
||||
/// The real menu tables: both are found, so every language command is a known id.
|
||||
#[test]
|
||||
fn reads_the_language_commands_from_menus_rs() {
|
||||
let menus = include_str!("../../crates/ui-egui/src/menus.rs");
|
||||
let ids = ui_command_ids(menus);
|
||||
for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese"] {
|
||||
assert!(ids.iter().any(|known| known == id), "{id} not found");
|
||||
}
|
||||
assert_eq!(ids.iter().filter(|id| id.as_str() == "app.language.english").count(), 1, "one Language menu entry per language");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue