mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 20:48:33 -06:00
M16: add Traditional Chinese UI, menus and stock preset localization
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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}件"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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 | ⬜ | | |
|
||||
|
||||
Reference in New Issue
Block a user