LR-BEHAV-LOCALIZE: Traditional Chinese (Taiwan) UI, stock preset and date localisation

Port of PR #198 onto the language table:
- Traditional Chinese (Taiwan) catalogs as a table entry: code zh-hant, name
  繁體中文(台灣), script Hant; zh-TW / zh-HK / zh-Hant tags reach it; command
  app.language.traditionalChinese
- display-only helpers: builtin_label (stock preset/profile names), history_label
  (generated history steps, user preset names verbatim), source_label (library
  headings, album names verbatim), date_group_label / display_time
- menu, dialog and panel strings (profiles, presets, curves, crop, versions,
  history, grid, date sidebar, About links) go through the catalogs
- a language command and ui.set apply the language at once; a native-menu test
  checks that every language switch rebuilds the menu titles

Adapted while porting (Claude):
- date headings and capture times are localised for every language from its
  *-formats.json date patterns, not only for zh-TW; ja and zh-hans gain the date
  patterns, weekday names and "Unknown Date", and zh-hans the new menu messages
  (ja's come from the PR)
- plain catalogs may cover different messages (English fallback); the test
  reports the gaps instead of failing. The test requiring every engine command
  label to be translated is dropped
- preset groups stay grouped by name: a user preset in a stock-named group is
  not split into a separate group (the group header is translated when it holds
  stock presets); the PR's split was a side effect of labelling
- no Traditional Chinese face in craft-fonts yet: Hant prefers the Simplified
  Chinese face (which carries the Traditional characters) over the Japanese ones
- zh-hant translations for the messages main added since the PR branched

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sakamoto824
2026-10-07 06:54:26 -04:00
committed by Brandon Thomas
co-authored by Codex Claude Opus 5.5
parent 269934caca
commit ca0c569863
24 changed files with 1804 additions and 78 deletions
+3
View File
@@ -344,6 +344,9 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
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).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
+3 -3
View File
@@ -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; Chinese/Japanese/English UI (see docs/localization.md); remaining technical errors and date formats; 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; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; 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 ✅; Chinese/Japanese/English localisation 🟡; accessibility and further locales ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
@@ -194,7 +194,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
## Chinese and Japanese interfaces, and text watermarks
English, Simplified Chinese and Japanese interface languages are persisted in UI state, and the
English, Simplified Chinese, Traditional Chinese (Taiwan) 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:
+21
View File
@@ -419,6 +419,27 @@ impl NativeMenu {
mod tests {
use super::*;
/// The menu model's titles stay English, so only the language can tell the native menu to
/// rebuild its titles (and the macOS app menu): every language switch — also between two CJK
/// languages — must change the structure key.
#[test]
fn switching_language_rebuilds_native_menu_structure() {
use lightcraft_ui_egui::i18n::{Locale, set_language};
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
set_language(Locale::En);
let bar = menu_bar(&app);
let mut seen = std::collections::BTreeSet::new();
for language in Locale::ALL {
set_language(*language);
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
}
set_language(Locale::ZhHant);
assert_eq!(label_text("File"), "檔案");
assert_eq!(label_text("Settings…"), "設定…");
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
set_language(Locale::En);
}
#[test]
fn shortcuts_map_to_accelerators() {
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
+10 -1
View File
@@ -26,6 +26,13 @@ impl CraftFont {
pub fn is_mincho(&self) -> bool {
self.family.contains("Mincho")
}
/// Whether the font can stand in for `script`: it is meant for it, or — until craft-fonts has a
/// Traditional Chinese face — it is a Simplified Chinese face, which carries the Traditional
/// characters too (with mainland glyph forms; still closer than the Japanese faces).
pub fn serves(&self, script: &str) -> bool {
self.covers(script) || (script == "Hant" && self.covers("Hans"))
}
}
/// The craft-fonts faces for Japanese, in `fonts`' order (empty without craft-fonts).
@@ -55,7 +62,7 @@ pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Ve
(
// 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.serves(script),
font.scripts.len() != 1,
font.family != "BIZ UDPGothic",
font.style != style,
@@ -99,6 +106,8 @@ mod tests {
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"));
// No Traditional Chinese face yet: the Chinese face stands in before the Japanese ones.
assert_eq!(first("Hant"), Some("Noto Sans CJK SC"));
// 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);
+16 -1
View File
@@ -103,5 +103,20 @@
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚",
" ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)",
"Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し"
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し",
"Auto ({})": "自動 ({})",
"Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})",
"Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
"Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
"Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
"Label Set: {name}": "ラベルセット:{name}",
"Quick Develop: {name}": "クイック現像: {name}",
"Reset {name}": "{name}をリセット",
"Updated “{name}”": "「{name}」を更新しました",
"{month}": "{month}月",
"{month} {year}": "{year}年{month}月",
"{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
"{weekday}, {day}": "{day}日({weekday})",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
"{year}": "{year}年"
}
+9 -1
View File
@@ -1019,5 +1019,13 @@
"Back Up Library…": "ライブラリをバックアップ…",
"Restore Library from Backup…": "バックアップからライブラリを復元…",
"In Recently Deleted": "最近削除した写真内",
"Reload from Disk": "ディスクから再読み込み"
"Reload from Disk": "ディスクから再読み込み",
"Monday": "月曜日",
"Tuesday": "火曜日",
"Wednesday": "水曜日",
"Thursday": "木曜日",
"Friday": "金曜日",
"Saturday": "土曜日",
"Sunday": "日曜日",
"Unknown Date": "日付不明"
}
+16 -1
View File
@@ -103,5 +103,20 @@
"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} · 右键单击 ▸ 恢复,或永久删除后重新导入"
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入",
"Auto ({})": "自动({})",
"Build 1:1 Previews ({scope})": "生成 1:1 预览({scope})",
"Build Smart Previews ({scope})": "生成智能预览({scope})",
"Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})",
"Discard Smart Previews ({scope})": "丢弃智能预览({scope})",
"Label Set: {name}": "标签集:{name}",
"Quick Develop: {name}": "快速编辑:{name}",
"Reset {name}": "重置{name}",
"Updated “{name}”": "已更新“{name}”",
"{verb} {n} Photo{}": "{verb} {n} 张照片{:.0}",
"{year}": "{year}年",
"{month}": "{month}月",
"{month} {year}": "{year}年{month}月",
"{weekday}, {day}": "{day}日 {weekday}",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}"
}
+9 -1
View File
@@ -1019,5 +1019,13 @@
"Back Up Library…": "备份图库…",
"Restore Library from Backup…": "从备份恢复图库…",
"In Recently Deleted": "在“最近删除”中",
"Reload from Disk": "从磁盘重新读取"
"Reload from Disk": "从磁盘重新读取",
"Monday": "星期一",
"Tuesday": "星期二",
"Wednesday": "星期三",
"Thursday": "星期四",
"Friday": "星期五",
"Saturday": "星期六",
"Sunday": "星期日",
"Unknown Date": "未知日期"
}
+122
View File
@@ -0,0 +1,122 @@
{
" · {skipped} skipped (file exists)": " · 已略過 {skipped} 個(檔案已存在)",
" · {} duplicate{} skipped": " · 已略過 {} 張重複照片{:.0}",
" · {} failed: {}": " · {} 個失敗:{}",
" · {} moved": " · 已移動 {} 個",
" · {} not readable": " · {} 個無法讀取",
", {failed} file{} not readable": ",{failed} 個檔案無法讀取{:.0}",
"Add {local_n} to My Photos": "將 {local_n} 張加入我的照片",
"Add {} Photo{}": "新增 {} 張照片{:.0}",
"Add “{}” to Favorites": "將「{}」加入喜好項目",
"Added {n} photo{} to My Photos": "已將 {n} 張照片加入我的照片{:.0}",
"Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}",
"Adding photos… {} of {}": "正在新增照片… {}/{}",
"All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。",
"Auto ({})": "自動({})",
"Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片",
"Automatic ({} MB)": "自動({} MB)",
"Build 1:1 Previews ({scope})": "建立 1:1 預覽({scope})",
"Build Smart Previews ({scope})": "建立智慧型預覽({scope})",
"Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})",
"Click {}:1": "點擊時 {}: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}」",
"Discard Smart Previews ({scope})": "捨棄智慧型預覽({scope})",
"Duplicate \"{label}\"": "複製「{label}」",
"Example: {example}": "範例:{example}",
"Exported {n} photo{}": "已匯出 {n} 張照片{:.0}",
"Exported {ok} of {total} photo{}": "已匯出 {ok}/{total} 張照片{:.0}",
"Exported {} point curve preset{}": "已匯出 {} 個點曲線預設集{:.0}",
"Exported {} preset{}": "已匯出 {} 個預設集{:.0}",
"Exporting {} of {total}": "正在匯出第 {}/{total} 張",
"Filter bar — {active} active filter{}": "篩選列 — 已套用 {active} 個篩選條件{:.0}",
"Healed {n} dust spot{}": "已修復 {n} 個灰塵斑點{:.0}",
"Import cancelled · {} photo{} added": "已取消匯入 · 已加入 {} 張照片{:.0}",
"Imported {n} point curve preset{}": "已匯入 {n} 個點曲線預設集{:.0}",
"Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}",
"Imported {} photo{}": "已匯入 {} 張照片{:.0}",
"Label Set: {name}": "標籤集:{name}",
"Matches: {}": "符合條件:{}",
"Merging… {stage} {:.0}%": "正在合併… {stage} {:.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} — 只貼上已複製的設定",
"Preset: {name}": "預設集:{name}",
"Preview only — editing the camera's embedded JPEG ({})": "僅供預覽 — 正在編輯相機內嵌的 JPEG({})",
"Preview only — {}": "僅供預覽 — {}",
"Profile: {}": "描述檔:{}",
"Proof Copy ({})": "校樣副本({})",
"Proof Preview · {}": "校樣預覽 · {}",
"Quick Develop · {n} photos": "快速顯像 · {n} 張照片",
"Quick Develop: {name}": "快速顯像:{name}",
"Rated {}": "評分:{}",
"Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}",
"Reading photos… {} of {}": "正在讀取照片… {}/{}",
"Redo {}": "重做 {}",
"Remove “{}” from Favorites": "將「{}」從喜好項目移除",
"Rename {n} Photos…": "重新命名 {n} 張照片…",
"Rename “{name}”": "重新命名「{name}」",
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
"Reset {name}": "重設{name}",
"Reset {} Channel": "重設 {} 通道",
"Saved export preset “{}”": "已儲存匯出預設集「{}」",
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
"Showing {n} of {}": "顯示 {n}/{} 張",
"Smart album: {}": "智慧型相簿:{}",
"Undo {}": "復原 {}",
"Updated “{name}”": "已更新「{name}」",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。",
"Version: {}": "版本:{}",
"none (CPU rendering): {why}": "無(使用 CPU 算繪):{why}",
"the {n} selected photos": "選取的 {n} 張照片",
"the {} photos in view": "目前顯示的 {} 張照片",
"these {count} photos": "這 {count} 張照片",
"{count} smart preview{} · {:.1} MB": "{count} 張智慧型預覽{:.0} · {:.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": "{matching}/{t} 張照片",
"{matching} photos": "{matching} 張照片",
"{mode} spot {} of {}": "{mode}斑點:第 {}/{} 個",
"{month}": "{month}月",
"{month} {year}": "{year}年{month}月",
"{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} 項變更{:.0}已保留在記憶體,但尚未寫入磁碟:{e}\nLightCraft 會自動重試;現在結束程式會遺失{}。",
"{n} found · {sel} selected": "找到 {n} 張 · 已選取 {sel} 張",
"{n} photos": "{n} 張照片",
"{n} photos in {} are hidden by the filters above": "{}中的 {n} 張照片被上方篩選條件隱藏",
"{n} photo{}": "{n} 張照片{:.0}",
"{n} photo{} match · updates automatically as photos change": "{n} 張照片符合{:.0} · 隨照片變更自動更新",
"{n} photo{} now · updates automatically as photos change": "目前 {n} 張照片{:.0} · 隨照片變更自動更新",
"{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}",
"{weekday}, {day}": "{day}日 {weekday}",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
"{what} merge added": "已新增{what}合併照片",
"{what} merge failed: {e}": "{what}合併失敗:{e}",
"{year}": "{year}年",
"{} and higher": "{} 以上",
"{} files": "{} 個檔案",
"{} files and {f} folder{}": "{} 個檔案與 {f} 個資料夾{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "已繪製 {}/4 條參考線 — 沿應為垂直或水平的線條拖曳。",
"{} photos": "{} 張照片",
"{} photo{} not readable": "{} 張照片無法讀取{:.0}",
"{} spot(s) on this photo": "此照片有 {} 個斑點",
"· {dups} already in the library": "· {dups} 張已在照片庫中",
"· {n} photo{}": "· {n} 張照片{:.0}",
"… and {more} more": "…以及另外 {more} 個",
" ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)",
"Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入"
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -267,6 +267,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
u.dialog = app.ui.dialog.clone();
u.status = app.ui.status.clone();
app.ui = u;
crate::i18n::set_language(app.ui.language);
ctx.request_repaint();
ok(serde_json::to_value(&app.ui).unwrap_or_default())
}
+215 -19
View File
@@ -118,6 +118,7 @@ macro_rules! language_table {
language_table! {
En, "en";
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
}
@@ -183,6 +184,93 @@ fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> {
// `locales/*-formats.json`.
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
/// exactly as its owner wrote it.
pub fn builtin_label(source: &str, builtin: bool) -> &str {
if builtin { tr(source) } else { source }
}
/// An Activity (history) step for display. The step's stored label stays English; generated steps
/// are translated around the names they carry, and a user preset's name is kept verbatim.
pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String {
if let Some(name) = source.strip_prefix("Preset: ") {
// A user preset may share a stock preset's name: then the step could be either, so it
// keeps the name as written.
let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name);
return tr_format!("Preset: {name}", name = builtin_label(name, builtin));
}
if let Some(name) = source.strip_prefix("Reset ") {
return tr_format!("Reset {name}", name = tr(name));
}
if let Some(name) = source.strip_prefix("Quick Develop: ") {
return tr_format!("Quick Develop: {name}", name = tr(name));
}
tr(source).to_string()
}
/// A library source's heading (All Photos, Recently Deleted…) in the UI language; an album's
/// name is the user's and stays verbatim.
pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String {
let label = source.label(catalog);
if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() }
}
/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full
/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the
/// catalog's own wording; every other language formats it with its `*-formats.json` date
/// patterns and weekday names. The grouping keys themselves never change.
pub fn date_group_label(key: &str, short: bool) -> String {
let label = lightcraft_catalog::dates::group_label(key);
if language() == Locale::En {
return if short {
match key.len() {
// "September 2026" → "September"
7 => label.split(' ').next().unwrap_or(&label).to_string(),
// "Sunday, 20 September 2026" → "Sunday, 20"
10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(),
_ => label,
}
} else {
label
};
}
let Some(year) = key.get(..4).and_then(|y| y.parse::<u32>().ok()) else { return tr(&label).to_string() };
if key.len() == 4 {
return tr_format!("{year}", year = year);
}
let Some(month) = key.get(5..7).and_then(|m| m.parse::<u32>().ok()).filter(|m| (1..=12).contains(m)) else {
return tr(&label).to_string();
};
if key.len() == 7 {
return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) };
}
if key.len() == 10
&& let Some(weekday) = lightcraft_catalog::dates::weekday(key)
&& let Some(day) = key.get(8..10).and_then(|d| d.parse::<u32>().ok())
{
let weekday = tr(weekday);
return if short {
tr_format!("{weekday}, {day}", weekday = weekday, day = day)
} else {
tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year)
};
}
tr(&label).to_string()
}
/// A capture time for display in the UI language (English: "March 30, 2022 at 10:11:11 PM");
/// the metadata itself is never rewritten, and a value that doesn't parse is shown as it is.
pub fn display_time(iso: &str) -> String {
if language() == Locale::En || lightcraft_catalog::dates::normalize_iso(iso).is_none() {
return lightcraft_catalog::dates::display_time(iso);
}
let date = date_group_label(iso.get(..10).unwrap_or(iso), false);
match iso.get(11..).filter(|time| !time.is_empty()) {
Some(time) => format!("{date} {time}"),
None => date,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -205,10 +293,11 @@ mod tests {
}
}
/// The catalogs of the translations agree: same keys, same format placeholders. A source string
/// added to one language but forgotten in another fails here.
/// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover
/// different sets of messages (a message a language lacks shows in English): the gaps are
/// reported, not failed, so a translation can grow at its own pace.
#[test]
fn catalogs_agree_on_keys_and_placeholders() {
fn catalogs_agree_on_placeholders_and_report_gaps() {
let fields = |text: &str| {
let mut out: Vec<String> = Vec::new();
let mut rest = text;
@@ -221,18 +310,15 @@ mod tests {
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:?}");
let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect();
for language in &translated {
let catalog = language.catalog();
for (key, value) in catalog {
assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}");
}
for key in b.keys() {
assert!(a.contains_key(key), "{language:?} translates {key:?}, which {first:?} does not");
let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect();
if !missing.is_empty() {
eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len());
}
}
}
@@ -245,6 +331,12 @@ mod tests {
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-hant"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant));
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);
@@ -313,7 +405,7 @@ mod tests {
// 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();
lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(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());
@@ -360,8 +452,12 @@ 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.traditionalChinese", Locale::ZhHant),
("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 {
@@ -479,9 +575,9 @@ mod tests {
/// family, Monospace included.
#[test]
fn craft_fonts_render_cjk_in_the_ui() {
let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字")];
let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")];
for (language, sample) in samples {
if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.covers(language.script())) {
if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) {
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
continue;
}
@@ -531,4 +627,104 @@ mod tests {
}
set_language(Locale::En);
}
/// Traditional Chinese through the menu command and the control channel, persisted, with
/// localised headings and dates; ids and user text are untouched.
#[test]
fn traditional_chinese_switches_through_menu_and_control_and_persists() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::ZhHant);
// Applied at once, not on the next frame.
assert_eq!(tr("File"), "檔案");
assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片");
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
assert_eq!(date_group_label("2026-09-20", true), "20日 星期日");
assert_eq!(date_group_label("2026-09", false), "2026年9月");
assert_eq!(date_group_label("2026-09", true), "9月");
assert_eq!(date_group_label("2026", false), "2026年");
assert_eq!(date_group_label("", false), "未知日期");
assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05");
assert_eq!(display_time("a-user-value"), "a-user-value");
assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true));
assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false));
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] {
assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color");
}
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩");
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"zh-hant""#), "{saved}");
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::ZhHant);
let ctx = egui::Context::default();
app.ui.language = Locale::En;
for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] {
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") };
assert_eq!(reply["ok"], true, "{code}");
assert_eq!(app.ui.language, Locale::ZhHant, "{code}");
assert_eq!(language(), Locale::ZhHant, "{code} applies at once");
}
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") };
assert_eq!(reply["ok"], false);
assert_eq!(app.ui.language, Locale::ZhHant);
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
let chinese_ids = ids(&app);
app.run("app.language.english", serde_json::json!({})).unwrap();
assert_eq!(tr("File"), "File");
assert_eq!(chinese_ids, ids(&app));
}
/// Dates follow every language's own patterns; English keeps the catalog's wording.
#[test]
fn date_headings_in_every_language() {
set_language(Locale::En);
assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026");
assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20");
assert_eq!(date_group_label("2026-09", true), "September");
assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05"));
set_language(Locale::Ja);
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)");
assert_eq!(date_group_label("2026-09", true), "9月");
set_language(Locale::ZhHans);
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
for language in Locale::ALL {
set_language(*language);
// A key that is not a date never panics and never shows a raw pattern.
for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] {
assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}");
assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}");
}
}
set_language(Locale::En);
}
/// Stock presets and panel headers are translated; a user preset keeps its name, also when it
/// shares a stock preset's name.
#[test]
fn traditional_chinese_presets_and_panel_headers_are_painted() {
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.presets = true;
let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone();
app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin });
for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] {
app.ui.right = panel;
let text = painted_text(&ctx, &mut app, Locale::ZhHant);
assert!(text.contains(title), "{text}");
assert!(!text.contains("History") && !text.contains("Versions"), "{text}");
assert!(text.contains("暖光"), "stock preset: {text}");
assert!(text.contains("Warm Glow"), "user preset: {text}");
assert!(text.contains("色彩"), "stock group: {text}");
}
assert_eq!(history_label("Exposure", &app.session.presets), "曝光");
assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片");
assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow");
set_language(Locale::En);
}
}
+5
View File
@@ -15,6 +15,7 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static
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.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
];
@@ -30,6 +31,7 @@ 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.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
_ => None,
}
@@ -231,6 +233,9 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if let Some(language) = language_from_command(id) {
app.ui.language = language;
// Immediately, not on the next frame: the reply and anything else run this frame
// (menus rebuilt from it, toasts) are already in the new language.
crate::i18n::set_language(app.ui.language);
return Some(Ok(json!(app.ui.language)));
}
let ctx = egui::Context::default();
+5 -5
View File
@@ -466,7 +466,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.separator();
}
builtin = b;
if ui.selectable_label(false, &p.name).clicked() {
if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() {
chosen = Some(p.name);
}
}
@@ -701,7 +701,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => crate::import::body(app, ui, opts),
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
Dialog::ConfirmDelete { count } => {
let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
}
@@ -710,7 +710,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE))
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
.min_size(egui::vec2(260.0, 34.0));
let r = ui.add(discord).on_hover_text(crate::links::DISCORD);
@@ -724,7 +724,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
("Source code on GitHub", crate::links::GITHUB),
("ArtCraft — more creative apps", crate::links::WEBSITE),
] {
let r = ui.link(label).on_hover_text(url);
let r = ui.link(crate::i18n::tr(label)).on_hover_text(url);
if r.clicked() {
let _ = crate::links::open(app, url);
}
@@ -775,7 +775,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => {
let n = opts.selected_paths().len();
let verb = if opts.copy && opts.move_files { "Move" } else { "Import" };
add_label = format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" });
add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n);
add_label.as_str()
}
Dialog::Merge { .. } => "Merge",
+11 -10
View File
@@ -140,8 +140,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim));
let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
let r =
crate::widgets::dropdown(ui, "profile", if name == "Color" { crate::i18n::tr("Color") } else { name }, t.font(15.0), t.text_label);
let r = crate::widgets::dropdown(ui, "profile", crate::i18n::tr(name), t.font(15.0), t.text_label);
egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() {
@@ -356,7 +355,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
key: &str,
p: &'static lightcraft_engine::presets::ProfileInfo,
pick: &mut (Option<&'static str>, Option<&'static str>)| {
let r = ui.selectable_label(p.id == cur, p.name);
let r = ui.selectable_label(p.id == cur, crate::i18n::tr(p.name));
register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect);
if r.clicked() {
pick.0 = Some(p.id);
@@ -366,7 +365,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
}
};
let heading = |ui: &mut egui::Ui, s: &str| {
ui.label(egui::RichText::new(s).font(t.semibold(11.5)).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim));
};
for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] {
let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect();
@@ -380,7 +379,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
ui.separator();
}
for g in profile_groups() {
let r = ui.menu_button(g, |ui| {
let r = ui.menu_button(crate::i18n::tr(g), |ui| {
ui.set_min_width(170.0);
for p in PROFILES.iter().filter(|p| p.group == g) {
item(ui, "group", p, &mut pick);
@@ -392,9 +391,9 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
if let Some(p) = profile(cur) {
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
let label = if fav {
crate::i18n::tr_format!("Remove “{}” from Favorites", p.name)
crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::tr(p.name))
} else {
crate::i18n::tr_format!("Add “{}” to Favorites", p.name)
crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::tr(p.name))
};
let r = ui.button(label);
register(ui.ctx(), "profileMenu:toggleFavorite", r.rect);
@@ -414,7 +413,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
let mut s = d.clone();
s.profile.id = p.id.to_string();
s.profile.amount = 100.0;
app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s });
app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s });
}
if let Some(id) = pick.0 {
let _ = app.run("develop.profile", json!({"id": id}));
@@ -811,7 +810,9 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label));
let current = matching_preset(&app.session, &d.curve);
let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text);
let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name));
let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin);
let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text);
egui::Popup::menu(&r).show(|ui| {
ui.set_min_width(180.0);
let presets = all_presets(&app.session);
@@ -821,7 +822,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
user_seen = true;
ui.separator();
}
let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), &p.name);
let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin));
register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect);
if r.clicked() {
let _ = app.run("curve.applyPreset", json!({"name": p.name}));
+9 -5
View File
@@ -150,7 +150,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
folder_header(app, ui, hr, &b, &ids, local, &cnt)
}
None => {
let title = app.session.source.label(&app.session.catalog);
let title = crate::i18n::source_label(app.session.source, &app.session.catalog);
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
}
@@ -164,7 +164,11 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if !chips.is_empty() {
let body = match total {
Some(n) if n > 0 => {
crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog), n = n)
crate::i18n::tr_format!(
"{n} photos in {} are hidden by the filters above",
crate::i18n::source_label(app.session.source, &app.session.catalog),
n = n
)
}
_ => "Remove a filter above, or choose Clear all".to_string(),
};
@@ -409,7 +413,7 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids:
p.rect_filled(r, 0.0, t.canvas);
p.hline(r.x_range(), r.bottom(), Stroke::new(1.0, t.divider));
}
let g = p.layout_no_wrap(run.label.clone(), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label });
let g = p.layout_no_wrap(crate::i18n::date_group_label(&run.key, false), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label });
let x = r.left() + 8.0;
let gw = g.size().x;
p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text);
@@ -520,7 +524,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
.collect();
if parts.is_empty() { "—".to_string() } else { parts.join(" · ") }
}
"date" => photo.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "No date".into()),
"date" => photo.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| crate::i18n::tr("No date").into()),
_ => photo.file_name.rsplit_once('.').map(|(n, _)| n.to_string()).unwrap_or(photo.file_name.clone()),
};
p.text(pos2(r.left() + 8.0, r.top() + 12.0), Align2::LEFT_CENTER, name, t.font(10.5), t.text_dim);
@@ -786,7 +790,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.separator();
ui.menu_button(crate::i18n::tr("Set Rating"), |ui| {
for r in 0..=5 {
if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() {
if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() {
let _ = app.run("photo.rate", json!({"rating": r}));
}
}
+3 -5
View File
@@ -120,14 +120,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("By Date"), t.semibold(13.5), t.text_label);
for g in app.caches.date_groups(&app.session.catalog).iter() {
// year → month → day; a click filters by that prefix, the triangle opens a level
if date_row(app, ui, &g.year, &g.year, g.count, 0.0) {
if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) {
for (m, n) in &g.months {
let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string();
let label = crate::i18n::date_group_label(m, true);
if date_row(app, ui, m, &label, *n, 16.0) {
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
let label = lightcraft_catalog::dates::group_label(d);
// "Sunday, 20 September 2026" → "Sunday, 20"
let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string();
let label = crate::i18n::date_group_label(d, true);
date_row(app, ui, d, &label, *n, 32.0);
}
}
+13 -4
View File
@@ -85,6 +85,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
groups.entry(p.group.clone()).or_default().push(p.clone());
}
for (g, items) in groups {
// A group holding stock presets is a stock group (shown in the UI language), even
// when user presets share its name; a group of user presets keeps its name.
let builtin = items.iter().any(|p| p.builtin);
let open_id = egui::Id::new(("preset-group", &g));
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
@@ -95,7 +98,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, &g, t.semibold(13.0), t.text_label);
ui.painter().text(
pos2(r.left() + 36.0, r.center().y),
Align2::LEFT_CENTER,
crate::i18n::builtin_label(&g, builtin),
t.semibold(13.0),
t.text_label,
);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
@@ -108,7 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
continue;
}
for pr in items {
let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite);
let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
@@ -156,7 +165,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if builtin {
@@ -168,7 +177,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.clicked()
{
match app.run("preset.update", json!({"id": pid})) {
Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")),
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)),
Err(e) => app.toast(ui.ctx(), e),
}
}
+6 -6
View File
@@ -35,7 +35,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim);
ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, name, t.font(14.0), t.text_label);
ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr(name), t.font(14.0), t.text_label);
if d.profile.id != "lc.color" {
super::edit::control(app, ui, &d, "profile.amount", true);
ui.add_space(6.0);
@@ -60,7 +60,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label);
ui.painter().text(pos2(r.right() - 22.0, r.center().y), Align2::RIGHT_CENTER, list.len().to_string(), t.font(12.0), t.text_dim);
if resp.clicked() {
ui.data_mut(|m| m.insert_temp(open_id, !open));
@@ -90,7 +90,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
&& let Some(p) = profile(&saved_profile_id)
{
let s = with_profile(&d, p);
app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s });
app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s });
}
} else {
ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id));
@@ -129,7 +129,7 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside);
}
let name_color = if applied { t.text } else { t.text_label };
painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, p.name, t.font(12.0), name_color);
painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, crate::i18n::tr(p.name), t.font(12.0), name_color);
// favourite star (always shown when set, on hover otherwise)
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0));
@@ -140,10 +140,10 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE });
}
if resp.hovered() && !applied && !over_star {
app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s.clone() });
app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s.clone() });
ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string())));
}
let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group));
let resp = resp.on_hover_text(format!("{} ({})", crate::i18n::tr(p.name), crate::i18n::tr(p.group)));
if resp.clicked() {
if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) {
let _ = app.run("profile.favorite", json!({"id": p.id}));
+19 -8
View File
@@ -48,7 +48,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
pub fn header(ui: &mut egui::Ui, title: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text);
ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(15.0), t.text);
}
pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) {
@@ -69,7 +69,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
ui.label(crate::i18n::tr("Aspect Ratio"));
let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into());
let r = ui.add(egui::Button::new(cur).frame(false));
let r = ui.add(egui::Button::new(crate::i18n::tr(&cur)).frame(false));
register(ui.ctx(), "button:cropAspect", r.rect);
egui::Popup::menu(&r).show(|ui| {
for (label, a) in [
@@ -84,7 +84,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
("16 × 9", "16x9"),
("16 × 10", "16x10"),
] {
if ui.button(label).clicked() {
if ui.button(crate::i18n::tr(label)).clicked() {
let _ = app.run("crop.aspect", json!({"aspect": a}));
}
}
@@ -494,7 +494,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() {
let _ = app.run("dialog.captureTime", json!({}));
}
let cap = p.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "—".into());
let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into());
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate());
});
@@ -651,7 +651,7 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim));
let id = egui::Id::new(("info-field", key));
let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string());
let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) };
@@ -830,7 +830,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false);
let named_n = p.versions.iter().filter(|v| !v.auto).count();
let items = [("Named", "named"), ("Auto", "auto")];
let labels = [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), format!("Auto ({})", p.versions.len() - named_n)];
let labels =
[crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)];
let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect();
if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) {
auto = i == 1;
@@ -840,7 +841,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect();
if shown.is_empty() {
let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." };
ui.label(egui::RichText::new(msg).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim));
}
// newest first
for (i, v) in shown.into_iter().rev() {
@@ -907,6 +908,10 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
/// "Sep 30, 2026, 12:00 PM" from an ISO time.
fn short_time(iso: &str) -> String {
// Other languages use their own date patterns (`i18n::display_time`).
if crate::i18n::language() != crate::i18n::Locale::En {
return crate::i18n::display_time(iso);
}
let long = lightcraft_catalog::dates::display_time(iso);
// "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped
let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t)));
@@ -935,7 +940,13 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.painter().rect_filled(r, 3.0, t.hover);
}
paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon);
ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, &h.label, t.font(12.5), t.text_label);
ui.painter().text(
pos2(r.left() + 24.0, r.center().y),
Align2::LEFT_CENTER,
crate::i18n::history_label(&h.label, &app.session.presets),
t.font(12.5),
t.text_label,
);
if resp.clicked() {
let _ = app.run("history.restore", json!({"index": i}));
}
+1 -1
View File
@@ -13,7 +13,7 @@ LightCraft 的界面支持简体中文、日语和英语。语言设置保存在
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
原生桌面版不受影响。
- 日期分组标题(如 `Sunday, 20 September 2026`)沿用英文格式,尚未本地化。
- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
## 翻译的维护
+46
View File
@@ -0,0 +1,46 @@
# LightCraft 繁體中文(台灣)介面
從「編輯 → 語言 → 繁體中文(台灣)」切換,或開啟「設定 → 一般 → 語言」選擇。
macOS 的設定也可由「LightCraft → 設定…」或 `⌘,` 開啟。
語言立即生效,並儲存在應用程式設定的 `ui.json`,下次啟動會沿用。
英文、簡體中文、繁體中文與日文可隨時互相切換。
繁中翻譯涵蓋 macOS 原生選單、程式內選單與子選單、鍵盤快速鍵說明、照片編輯、
裁切、遮色片、匯入/匯出、設定、41 個內建編輯預設集與分類、內建描述檔、
曲線與匯出預設集、歷史紀錄/版本面板、內建照片來源標題、日期群組與拍攝時間、
照片庫開啟失敗提示及主要處理進度。
切換介面不會變更操作指令 ID、照片檔名、使用者命名的相簿、預設集或中繼資料。
未翻譯的技術錯誤、發行說明及部分新增文案仍使用英文。
## 建置與字型
使用既有 [craft-fonts](https://github.com/storytold/craft-fonts) 字型作為建置輸入:
```sh
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
craft-fonts 目前沒有繁體中文專用字型:桌面版先以 Noto Sans CJK SC 顯示繁中文字(字形為大陸規範寫法),其次才是日文字型;不在此儲存庫新增字型檔。
未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。
Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。
## 自動化與維護
`LIGHTCRAFT_LANGUAGE=zh-hant lightcraft-cli snapshot --demo -o zh-hant.png` 可繪製繁中畫面。
接受 `zh-hant`、`zh-Hant`、`zh-TW`、`zh_TW.UTF-8` 與 `zh-HK`;儲存時統一使用 `zh-hant`。
控制通道可切換語言:
```json
{"method":"ui.set","params":{"language":"zh-hant"}}
{"method":"ui.menu.invoke","params":{"command":"app.language.traditionalChinese"}}
```
靜態文案位於 `crates/ui-egui/locales/zh-hant.json`,含變數的訊息位於
`crates/ui-egui/locales/zh-hant-formats.json`。動態訊息由 Rust 在建置時檢查格式參數,
所有 `*-formats.json` 須保持相同英文鍵。新增語言的方法見 [`localization.md`](localization.md)。`{:.0}` 消耗英文複數字尾引數,繁中不顯示該字尾。
```sh
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests
```
測試涵蓋翻譯目錄、指令/選單文字、語言切換、設定保存、資料名稱保持原樣及桌面字形。
+11 -5
View File
@@ -1,22 +1,28 @@
# 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).
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
Chinese (`zh-hant`, Taiwan) 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), [`localization-zh-hant.md`](localization-zh-hant.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.
English source strings and must stay byte-for-byte identical. The plain catalog may grow at its
own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests
-- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The
formats catalog must carry every message (the build fails otherwise), including the date
patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and
capture times use in every language but English; weekday names go in the plain catalog.
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");
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
}
```
+2 -2
View File
@@ -503,8 +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-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-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `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-zh-hant.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan) 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. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), 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 | ⬜ | | |