M16: add Traditional Chinese UI, menus and stock preset localization

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
sakamoto824
2026-10-07 17:32:12 +08:00
co-authored by Codex
parent 5cee0c08d0
commit db640ed1d3
21 changed files with 1845 additions and 89 deletions
+6 -2
View File
@@ -334,16 +334,20 @@ 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):
**Japanese and Traditional Chinese interface text** needs 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 CJK interface text has 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:** choose **Edit → Language → 繁體中文(台灣)**, or **Settings → General → Language**.
The choice applies immediately and persists between launches, including native macOS menus and the Keyboard Shortcuts sheet.
See [繁體中文介面說明](docs/localization-zh-tw.md) for build, coverage and control-channel details.
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).
+10 -2
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; 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; English/Japanese/Traditional Chinese UI (see docs/localization-ja.md and docs/localization-zh-tw.md); remaining technical errors and other languages; 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 ✅; English/Japanese/Traditional Chinese localisation 🟡; accessibility and other locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
@@ -202,3 +202,11 @@ optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed.
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
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
## Traditional Chinese (Taiwan) interface
The persisted `zh-tw` language is available in Edit → Language and Settings → General → Language.
Static and compile-checked dynamic catalogs cover the existing Japanese UI surface, registered command labels,
native/in-window menus and the keyboard reference. Native macOS menus rebuild when changing locale.
Technical errors and release notes retain English fallback; full web glyph coverage remains unverified.
See [docs/localization-zh-tw.md](docs/localization-zh-tw.md).
+18 -1
View File
@@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
out.push(';');
}
}
let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese());
let mut s = format!("{:?};", lightcraft_ui_egui::i18n::language());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
@@ -419,6 +419,23 @@ impl NativeMenu {
mod tests {
use super::*;
#[test]
fn switching_from_english_to_chinese_rebuilds_native_menu_structure() {
use lightcraft_ui_egui::i18n::{Language, set_language};
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
set_language(Language::En);
let bar = menu_bar(&app);
let english = structure_of(&bar);
set_language(Language::ZhTw);
// Titles in the model remain English; the locale must force the native titles
// and the macOS app menu to be rebuilt even when no command ids changed.
assert_ne!(english, structure_of(&bar));
assert_eq!(label_text("File"), "檔案");
assert_eq!(label_text("Settings…"), "設定…");
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
set_language(Language::En);
}
#[test]
fn shortcuts_map_to_accelerators() {
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
+11 -5
View File
@@ -1,18 +1,24 @@
//! Compile translated format strings so Rust checks both languages' placeholders.
//! Compile translated format strings so Rust checks every language's placeholders.
use std::{collections::BTreeMap, 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")?)?;
println!("cargo:rerun-if-changed=locales/zh-tw-formats.json");
let japanese: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?;
let chinese: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string("locales/zh-tw-formats.json")?)?;
if !japanese.keys().eq(chinese.keys()) {
return Err("Format catalogs must contain the same English keys".into());
}
let mut source = String::from("macro_rules! tr_format {\n");
for (english, japanese) in messages {
for (english, japanese) in japanese {
let en = serde_json::to_string(&english)?;
let ja = serde_json::to_string(&japanese)?;
let zh = serde_json::to_string(chinese.get(&english).ok_or("Missing Chinese format")?)?;
source.push_str(&format!(
"({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n"
"({en} $(, $($args:tt)*)?) => {{ match $crate::i18n::language() {{ $crate::i18n::Language::Ja => format!({ja} $(, $($args)*)?), $crate::i18n::Language::ZhTw => format!({zh} $(, $($args)*)?), $crate::i18n::Language::En => 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)?;
fs::write(PathBuf::from(env::var("OUT_DIR")?).join("formats.rs"), source)?;
Ok(())
}
+19 -4
View File
@@ -10,9 +10,14 @@
"Add “{}” to Favorites": "「{}」をお気に入りに追加",
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚",
"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}",
@@ -20,6 +25,7 @@
"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}",
@@ -29,10 +35,12 @@
"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}を「最近削除した写真」に移動しますか?",
@@ -45,19 +53,23 @@
"Proof Copy ({})": "校正用コピー({})",
"Proof Preview · {}": "校正プレビュー・{}",
"Quick Develop · {n} photos": "クイック現像・{n}枚",
"Quick Develop: {name}": "クイック現像: {name}",
"Rated {}": "評価:{}",
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
"Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
"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}",
@@ -72,6 +84,8 @@
"{matching} of {t} photos": "{t}枚中{matching}枚",
"{matching} photos": "{matching}枚",
"{mode} spot {} of {}": "{mode}:{1}か所中{0}か所",
"{month}": "{month}",
"{month} {year}": "{month} {year}",
"{msg} ({failed} failed)": "{msg}({failed}件失敗)",
"{name} Label": "{name}のラベル",
"{name} Off": "{name}を無効にする",
@@ -86,8 +100,12 @@
"{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新",
"{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}",
"{what} merge added": "{what}合成を追加しました",
"{what} merge failed: {e}": "{what}合成に失敗:{e}",
"{year}": "{year}",
"{} and higher": "{}以上",
"{} files": "{}個のファイル",
"{} files and {f} folder{}": "{}個のファイルと{f}個のフォルダ{:.0}",
@@ -97,8 +115,5 @@
"{} spot(s) on this photo": "この写真に{}か所のスポット",
"· {dups} already in the library": "・{dups}枚は読み込み済み",
"· {n} photo{}": "・{n}枚{:.0}",
"… and {more} more": "…ほか{more}件",
"Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}",
"Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚"
"… and {more} more": "…ほか{more}件"
}
+119
View File
@@ -0,0 +1,119 @@
{
" · {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} 個"
}
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())
}
+259 -7
View File
@@ -3,7 +3,7 @@ use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
use serde::{Deserialize, Serialize};
include!(concat!(env!("OUT_DIR"), "/ja-formats.rs"));
include!(concat!(env!("OUT_DIR"), "/formats.rs"));
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Language {
@@ -12,25 +12,33 @@ pub enum Language {
En,
#[serde(rename = "ja")]
Ja,
#[serde(rename = "zh-tw", alias = "zh-TW", alias = "zh-Hant", alias = "zh-hant")]
ZhTw,
}
impl Language {
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
pub const ALL: [Self; 3] = [Self::En, Self::Ja, Self::ZhTw];
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::Ja => "日本語",
Self::ZhTw => "繁體中文(台灣)",
}
}
pub fn parse(code: &str) -> Option<Self> {
match code {
match code.to_ascii_lowercase().as_str() {
"en" => Some(Self::En),
"ja" => Some(Self::Ja),
"zh-tw" | "zh-hant" => Some(Self::ZhTw),
_ => None,
}
}
pub fn tr(self, source: &str) -> &str {
if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
match self {
Self::En => source,
Self::Ja => japanese().get(source).map(String::as_str).unwrap_or(source),
Self::ZhTw => traditional_chinese().get(source).map(String::as_str).unwrap_or(source),
}
}
}
@@ -39,15 +47,20 @@ thread_local! {
}
pub fn default_language() -> Language {
if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En }
std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|code| Language::parse(&code)).unwrap_or_default()
}
pub fn set_language(language: Language) {
LANGUAGE.with(|value| value.set(language));
}
pub fn language() -> Language {
LANGUAGE.with(Cell::get)
}
/// Kept for callers of the original two-language API.
pub fn is_japanese() -> bool {
LANGUAGE.with(|value| value.get() == Language::Ja)
language() == Language::Ja
}
fn japanese() -> &'static BTreeMap<String, String> {
@@ -60,16 +73,255 @@ fn japanese() -> &'static BTreeMap<String, String> {
})
}
fn traditional_chinese() -> &'static BTreeMap<String, String> {
static MESSAGES: OnceLock<BTreeMap<String, String>> = OnceLock::new();
MESSAGES.get_or_init(|| {
serde_json::from_str(include_str!("../locales/zh-tw.json")).unwrap_or_else(|error| {
log::error!("Invalid Traditional Chinese message catalog: {error}");
BTreeMap::new()
})
})
}
/// 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 }
language().tr(source)
}
/// Display names from stock presets/profiles; imported and editable names remain verbatim.
pub fn builtin_label(source: &str, builtin: bool) -> &str {
if builtin { tr(source) } else { source }
}
/// Display-only history labels. Preserve custom preset names within generated steps.
pub fn history_label<'a>(source: &'a str, presets: &'a [lightcraft_develop::Preset]) -> String {
if let Some(name) = source.strip_prefix("Preset: ") {
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()
}
/// Built-in library headings, without translating an album's editable name.
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() }
}
/// Calendar headings for the grid and the narrower date sidebar. ISO keys stay unchanged.
pub fn date_group_label(key: &str, short: bool) -> String {
let label = lightcraft_catalog::dates::group_label(key);
if language() != Language::ZhTw {
return if short {
match key.len() {
7 => label.split(' ').next().unwrap_or(&label).to_string(),
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()
}
/// Localized capture times for display; the source metadata is never rewritten.
pub fn display_time(iso: &str) -> String {
if language() != Language::ZhTw || 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::*;
#[test]
fn traditional_chinese_catalog_covers_commands_and_japanese_messages() {
let messages = traditional_chinese();
for key in japanese().keys() {
assert!(messages.get(key).is_some_and(|value| !value.is_empty()), "missing: {key}");
}
for preset in lightcraft_engine::presets::builtin() {
for key in [&preset.name, &preset.group] {
assert!(messages.contains_key(key), "missing built-in preset label: {key}");
}
}
for profile in lightcraft_engine::presets::PROFILES {
for key in [profile.name, profile.group] {
assert!(messages.contains_key(key), "missing profile label: {key}");
}
}
for command in lightcraft_engine::command_specs() {
assert!(messages.contains_key(command.label), "missing command: {}", command.label);
}
for (_, label, _, _) in crate::menus::UI_COMMANDS {
assert!(messages.contains_key(*label), "missing UI command: {label}");
}
}
#[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, Language::ZhTw);
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");
assert_eq!(tr("develop.set"), "develop.set");
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_eq!(serde_json::from_str::<crate::UiState>(&saved).unwrap().language, Language::ZhTw);
let ctx = egui::Context::default();
for code in ["zh-tw", "zh-TW", "zh-hant", "zh-Hant"] {
assert_eq!(Language::parse(code), Some(Language::ZhTw));
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 reply") };
assert_eq!(reply["ok"], true);
assert_eq!(app.ui.language, Language::ZhTw);
}
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 reply") };
assert_eq!(reply["ok"], false);
assert_eq!(app.ui.language, Language::ZhTw);
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));
}
#[test]
fn traditional_chinese_presets_and_panel_headers_are_painted() {
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.language = Language::ZhTw;
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(&mut app);
assert!(text.contains(title), "{text}");
assert!(!text.contains("History"), "{text}");
assert!(!text.contains("Versions"), "{text}");
assert!(text.contains("暖光"), "built-in preset: {text}");
assert!(text.contains("Warm Glow"), "user preset: {text}");
assert!(text.contains("色彩"), "built-in group: {text}");
assert!(text.contains("Color"), "user 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(Language::En);
}
fn painted_text(app: &mut crate::LightcraftApp) -> String {
fn collect(shape: &egui::epaint::Shape, text: &mut String) {
match shape {
egui::epaint::Shape::Text(shape) => {
text.push_str(&shape.galley.job.text);
text.push('\n');
}
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)),
_ => {}
}
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
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);
});
out.textures_delta.clear();
text.clear();
for shape in out.shapes {
collect(&shape.shape, &mut text);
}
}
text
}
#[test]
fn traditional_chinese_fonts_cover_static_and_dynamic_catalogs() {
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
eprintln!("skipped glyph coverage: build with CRAFT_FONTS_DIR");
return;
}
let formats: BTreeMap<String, String> = serde_json::from_str(include_str!("../locales/zh-tw-formats.json")).unwrap();
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);
let mut missing = std::collections::BTreeSet::new();
for message in traditional_chinese().values().chain(formats.values()).map(String::as_str).chain([Language::ZhTw.name()]) {
// egui reports false for glyphs of Monospace's first (replacement)
// face, Hack. Check its CJK fallback; proportional/semibold check all text.
for ch in message.chars().filter(|ch| !ch.is_whitespace()) {
if font.family == egui::FontFamily::Monospace && !('\u{4e00}'..='\u{9fff}').contains(&ch) {
continue;
}
if !fonts.has_glyph(&font, ch) {
missing.insert(ch);
}
}
}
assert!(missing.is_empty(), "Missing glyphs in {font:?}: {missing:?}");
}
});
}
#[test]
fn catalog_is_valid_and_contains_core_workflows() {
let messages: BTreeMap<String, String> = serde_json::from_str(include_str!("../locales/ja.json")).unwrap();
+29 -14
View File
@@ -272,6 +272,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
match id {
"app.language.english" => Some(u.language == crate::i18n::Language::En),
"app.language.japanese" => Some(u.language == crate::i18n::Language::Ja),
"app.language.traditionalChinese" => Some(u.language == crate::i18n::Language::ZhTw),
"develop.autoSync" => Some(app.session.auto_sync),
"view.photoCounts" => Some(u.show_counts),
"view.secondWindow" => Some(u.second_window),
@@ -316,12 +317,18 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String {
let n = app.session.selection.ids.len();
match id {
"edit.undo" => {
app.session.undo.last().map(|e| crate::i18n::tr_format!("Undo {}", crate::i18n::tr(&e.label))).unwrap_or_else(|| "Undo".into())
}
"edit.redo" => {
app.session.redo.last().map(|e| crate::i18n::tr_format!("Redo {}", crate::i18n::tr(&e.label))).unwrap_or_else(|| "Redo".into())
}
"edit.undo" => app
.session
.undo
.last()
.map(|e| crate::i18n::tr_format!("Undo {}", crate::i18n::history_label(&e.label, &app.session.presets)))
.unwrap_or_else(|| "Undo".into()),
"edit.redo" => app
.session
.redo
.last()
.map(|e| crate::i18n::tr_format!("Redo {}", crate::i18n::history_label(&e.label, &app.session.presets)))
.unwrap_or_else(|| "Redo".into()),
"photo.delete" if n > 1 => crate::i18n::tr_format!("Delete {n} Photos", n = n),
"photo.virtualCopy" if n > 1 => crate::i18n::tr_format!("Create {n} Virtual Copies", n = n),
"dialog.rename" if n > 1 => crate::i18n::tr_format!("Rename {n} Photos…", n = n),
@@ -341,19 +348,26 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
item(
"library.buildPreviews",
json!({"size": "standard", "edge": app.ui.settings.preview_edge}),
format!("Build Standard-Sized Previews ({scope})"),
crate::i18n::tr_format!("Build Standard-Sized Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
!running,
None,
),
item(
"library.buildPreviews",
json!({"size": "full"}),
crate::i18n::tr_format!("Build 1:1 Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
!running,
None,
),
item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None),
item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None),
MenuNode::Separator,
// (read and written on a worker thread: the originals may be on a slow drive)
item(
"library.smartPreviews",
json!({"background": true}),
format!("Build Smart Previews ({scope})"),
crate::i18n::tr_format!("Build Smart Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
app.session.media.smart_dir.is_some() && !running,
None,
@@ -361,7 +375,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
item(
"library.smartPreviews",
json!({"discard": true, "background": true}),
format!("Discard Smart Previews ({scope})"),
crate::i18n::tr_format!("Discard Smart Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
app.session.media.smart_dir.is_some() && !running,
None,
@@ -394,7 +408,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
.map(|(l, sc)| {
let name = format!("{l:?}");
let label = match app.session.catalog.custom_label_name(*l) {
Some(custom) => format!("{custom} ({name})"),
Some(custom) => format!("{custom} ({})", crate::i18n::tr(&name)),
None => name.clone(),
};
item(
@@ -418,7 +432,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
v.push(item(
"label.applySet",
json!({"name": name}),
format!("Label Set: {name}"),
crate::i18n::tr_format!("Label Set: {name}", name = name),
None,
true,
Some(current.as_deref() == Some(name)),
@@ -499,7 +513,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
item(
"library.selectBy",
json!({"label": name.to_lowercase()}),
crate::i18n::tr_format!("{name} Label", name = name),
crate::i18n::tr_format!("{name} Label", name = crate::i18n::tr(&name)),
None,
true,
None,
@@ -516,7 +530,8 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
v.push(MenuNode::Separator);
}
builtin = b;
v.push(item("app.export", json!({"preset": p.name, "background": true}), p.name, None, sel, None));
let label = crate::i18n::builtin_label(&p.name, b).to_string();
v.push(item("app.export", json!({"preset": p.name, "background": true}), label, None, sel, None));
}
v.push(MenuNode::Separator);
v.push(item("dialog.export", Value::Null, "Custom…", None, sel, None));
+10 -4
View File
@@ -13,6 +13,7 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static
pub const UI_COMMANDS: &[UiCommand] = &[
("app.language.english", "English", None, "Edit>Language"),
("app.language.japanese", "日本語", None, "Edit>Language"),
("app.language.traditionalChinese", "繁體中文(台灣)", 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)
@@ -151,10 +152,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
if app.ui.right == p {
app.ui.right = RightPanel::None;
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = name));
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name)));
} else {
app.ui.right = p;
app.toast(ctx, crate::i18n::tr_format!("{name} On", name = name));
app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name)));
if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) {
app.ui.view = ViewMode::Detail;
}
@@ -206,8 +207,13 @@ 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 matches!(id, "app.language.english" | "app.language.japanese" | "app.language.traditionalChinese") {
app.ui.language = match id {
"app.language.japanese" => crate::i18n::Language::Ja,
"app.language.traditionalChinese" => crate::i18n::Language::ZhTw,
_ => crate::i18n::Language::En,
};
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);
}
@@ -776,7 +776,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);
}
}
+16 -9
View File
@@ -80,22 +80,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let row_h = if thumbs { 54.0 } else { 26.0 };
let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let mut groups: BTreeMap<String, Vec<Preset>> = BTreeMap::new();
let mut groups: BTreeMap<(String, bool), Vec<Preset>> = BTreeMap::new();
for p in &app.session.presets {
groups.entry(p.group.clone()).or_default().push(p.clone());
groups.entry((p.group.clone(), p.builtin)).or_default().push(p.clone());
}
for (g, items) in groups {
let open_id = egui::Id::new(("preset-group", &g));
for ((g, builtin), items) in groups {
let open_id = egui::Id::new(("preset-group", &g, builtin));
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());
register(ui.ctx(), format!("presetGroup:{g}"), r);
let shared_group = app.session.presets.iter().any(|p| p.group == g && p.builtin != builtin);
register(ui.ctx(), if builtin || !shared_group { format!("presetGroup:{g}") } else { format!("presetGroup:user:{g}") }, r);
paint(
ui.painter(),
Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)),
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 +115,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 +163,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 +175,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}));
+18 -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,9 @@ 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 {
if crate::i18n::language() == crate::i18n::Language::ZhTw {
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 +939,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
@@ -91,7 +91,7 @@ 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);
choices(ui, "settingsLanguage", &crate::i18n::Language::ALL.map(|language| (language, language.name())), &mut app.ui.language);
crate::i18n::set_language(app.ui.language);
});
heading(ui, t, crate::i18n::tr("Library"));
+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_DIR` 的建置缺少 CJK 字形。
Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。
## 自動化與維護
`LIGHTCRAFT_LANGUAGE=zh-tw lightcraft-cli snapshot --demo -o zh-tw.png` 可繪製繁中畫面。
接受 `zh-tw`、`zh-TW`、`zh-hant` 與 `zh-Hant`;儲存時統一使用 `zh-tw`。
控制通道可切換語言:
```json
{"method":"ui.set","params":{"language":"zh-tw"}}
{"method":"ui.menu.invoke","params":{"command":"app.language.traditionalChinese"}}
```
靜態文案位於 `crates/ui-egui/locales/zh-tw.json`,含變數的訊息位於
`crates/ui-egui/locales/zh-tw-formats.json`。動態訊息由 Rust 在建置時檢查格式參數,
須與 `ja-formats.json` 保持相同英文鍵。`{:.0}` 消耗英文複數字尾引數,繁中不顯示該字尾。
```sh
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests
```
測試涵蓋翻譯目錄、指令/選單文字、語言切換、設定保存、資料名稱保持原樣及桌面字形。
+1 -1
View File
@@ -503,7 +503,7 @@ 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-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md`, `docs/localization-zh-tw.md`, `cmd:app.language.traditionalChinese` | English/Japanese/Traditional Chinese UI with persisted language preference and existing CJK regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Native macOS and in-window menus plus keyboard reference translated. Technical errors/release notes and other locales remain untranslated; full Traditional Chinese web glyph coverage unverified. |
| 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 | ⬜ | | |