mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-BEHAV-LOCALIZE: Traditional Chinese (Taiwan) UI, stock preset and date localisation
Port of PR #198 onto the language table: - Traditional Chinese (Taiwan) catalogs as a table entry: code zh-hant, name 繁體中文(台灣), script Hant; zh-TW / zh-HK / zh-Hant tags reach it; command app.language.traditionalChinese - display-only helpers: builtin_label (stock preset/profile names), history_label (generated history steps, user preset names verbatim), source_label (library headings, album names verbatim), date_group_label / display_time - menu, dialog and panel strings (profiles, presets, curves, crop, versions, history, grid, date sidebar, About links) go through the catalogs - a language command and ui.set apply the language at once; a native-menu test checks that every language switch rebuilds the menu titles Adapted while porting (Claude): - date headings and capture times are localised for every language from its *-formats.json date patterns, not only for zh-TW; ja and zh-hans gain the date patterns, weekday names and "Unknown Date", and zh-hans the new menu messages (ja's come from the PR) - plain catalogs may cover different messages (English fallback); the test reports the gaps instead of failing. The test requiring every engine command label to be translated is dropped - preset groups stay grouped by name: a user preset in a stock-named group is not split into a separate group (the group header is translated when it holds stock presets); the PR's split was a side effect of labelling - no Traditional Chinese face in craft-fonts yet: Hant prefers the Simplified Chinese face (which carries the Traditional characters) over the Japanese ones - zh-hant translations for the messages main added since the PR branched Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Brandon Thomas
co-authored by
Codex
Claude Opus 5.5
parent
269934caca
commit
ca0c569863
@@ -344,6 +344,9 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
|
||||
|
||||
|
||||
+3
-3
@@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Chinese/Japanese/English UI (see docs/localization.md); remaining technical errors and date formats; accessibility partial; headless UI tests time out under machine load |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
@@ -87,7 +87,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Chinese/Japanese/English localisation 🟡; accessibility and further locales ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
|
||||
|
||||
@@ -194,7 +194,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
English, Simplified Chinese and Japanese interface languages are persisted in UI state, and the
|
||||
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
|
||||
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
|
||||
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
|
||||
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
|
||||
|
||||
@@ -419,6 +419,27 @@ impl NativeMenu {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The menu model's titles stay English, so only the language can tell the native menu to
|
||||
/// rebuild its titles (and the macOS app menu): every language switch — also between two CJK
|
||||
/// languages — must change the structure key.
|
||||
#[test]
|
||||
fn switching_language_rebuilds_native_menu_structure() {
|
||||
use lightcraft_ui_egui::i18n::{Locale, set_language};
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
set_language(Locale::En);
|
||||
let bar = menu_bar(&app);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for language in Locale::ALL {
|
||||
set_language(*language);
|
||||
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
|
||||
}
|
||||
set_language(Locale::ZhHant);
|
||||
assert_eq!(label_text("File"), "檔案");
|
||||
assert_eq!(label_text("Settings…"), "設定…");
|
||||
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_map_to_accelerators() {
|
||||
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
|
||||
|
||||
@@ -26,6 +26,13 @@ impl CraftFont {
|
||||
pub fn is_mincho(&self) -> bool {
|
||||
self.family.contains("Mincho")
|
||||
}
|
||||
|
||||
/// Whether the font can stand in for `script`: it is meant for it, or — until craft-fonts has a
|
||||
/// Traditional Chinese face — it is a Simplified Chinese face, which carries the Traditional
|
||||
/// characters too (with mainland glyph forms; still closer than the Japanese faces).
|
||||
pub fn serves(&self, script: &str) -> bool {
|
||||
self.covers(script) || (script == "Hant" && self.covers("Hans"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Japanese, in `fonts`' order (empty without craft-fonts).
|
||||
@@ -55,7 +62,7 @@ pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Ve
|
||||
(
|
||||
// The language's own script first. A face dedicated to the script is preferred over a
|
||||
// multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`).
|
||||
!font.covers(script),
|
||||
!font.serves(script),
|
||||
font.scripts.len() != 1,
|
||||
font.family != "BIZ UDPGothic",
|
||||
font.style != style,
|
||||
@@ -99,6 +106,8 @@ mod tests {
|
||||
let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family);
|
||||
assert_eq!(first("Hans"), Some("Noto Sans CJK SC"));
|
||||
assert_eq!(first("Jpan"), Some("BIZ UDPGothic"));
|
||||
// No Traditional Chinese face yet: the Chinese face stands in before the Japanese ones.
|
||||
assert_eq!(first("Hant"), Some("Noto Sans CJK SC"));
|
||||
// Every CJK face is still offered to every language.
|
||||
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
|
||||
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
|
||||
|
||||
@@ -103,5 +103,20 @@
|
||||
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚",
|
||||
" ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)",
|
||||
"Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し"
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し",
|
||||
"Auto ({})": "自動 ({})",
|
||||
"Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})",
|
||||
"Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
|
||||
"Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
|
||||
"Label Set: {name}": "ラベルセット:{name}",
|
||||
"Quick Develop: {name}": "クイック現像: {name}",
|
||||
"Reset {name}": "{name}をリセット",
|
||||
"Updated “{name}”": "「{name}」を更新しました",
|
||||
"{month}": "{month}月",
|
||||
"{month} {year}": "{year}年{month}月",
|
||||
"{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
|
||||
"{weekday}, {day}": "{day}日({weekday})",
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
|
||||
"{year}": "{year}年"
|
||||
}
|
||||
|
||||
@@ -1019,5 +1019,13 @@
|
||||
"Back Up Library…": "ライブラリをバックアップ…",
|
||||
"Restore Library from Backup…": "バックアップからライブラリを復元…",
|
||||
"In Recently Deleted": "最近削除した写真内",
|
||||
"Reload from Disk": "ディスクから再読み込み"
|
||||
"Reload from Disk": "ディスクから再読み込み",
|
||||
"Monday": "月曜日",
|
||||
"Tuesday": "火曜日",
|
||||
"Wednesday": "水曜日",
|
||||
"Thursday": "木曜日",
|
||||
"Friday": "金曜日",
|
||||
"Saturday": "土曜日",
|
||||
"Sunday": "日曜日",
|
||||
"Unknown Date": "日付不明"
|
||||
}
|
||||
|
||||
@@ -103,5 +103,20 @@
|
||||
"Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张",
|
||||
" ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)",
|
||||
"Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入"
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入",
|
||||
"Auto ({})": "自动({})",
|
||||
"Build 1:1 Previews ({scope})": "生成 1:1 预览({scope})",
|
||||
"Build Smart Previews ({scope})": "生成智能预览({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})",
|
||||
"Discard Smart Previews ({scope})": "丢弃智能预览({scope})",
|
||||
"Label Set: {name}": "标签集:{name}",
|
||||
"Quick Develop: {name}": "快速编辑:{name}",
|
||||
"Reset {name}": "重置{name}",
|
||||
"Updated “{name}”": "已更新“{name}”",
|
||||
"{verb} {n} Photo{}": "{verb} {n} 张照片{:.0}",
|
||||
"{year}": "{year}年",
|
||||
"{month}": "{month}月",
|
||||
"{month} {year}": "{year}年{month}月",
|
||||
"{weekday}, {day}": "{day}日 {weekday}",
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}"
|
||||
}
|
||||
|
||||
@@ -1019,5 +1019,13 @@
|
||||
"Back Up Library…": "备份图库…",
|
||||
"Restore Library from Backup…": "从备份恢复图库…",
|
||||
"In Recently Deleted": "在“最近删除”中",
|
||||
"Reload from Disk": "从磁盘重新读取"
|
||||
"Reload from Disk": "从磁盘重新读取",
|
||||
"Monday": "星期一",
|
||||
"Tuesday": "星期二",
|
||||
"Wednesday": "星期三",
|
||||
"Thursday": "星期四",
|
||||
"Friday": "星期五",
|
||||
"Saturday": "星期六",
|
||||
"Sunday": "星期日",
|
||||
"Unknown Date": "未知日期"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
" · {skipped} skipped (file exists)": " · 已略過 {skipped} 個(檔案已存在)",
|
||||
" · {} duplicate{} skipped": " · 已略過 {} 張重複照片{:.0}",
|
||||
" · {} failed: {}": " · {} 個失敗:{}",
|
||||
" · {} moved": " · 已移動 {} 個",
|
||||
" · {} not readable": " · {} 個無法讀取",
|
||||
", {failed} file{} not readable": ",{failed} 個檔案無法讀取{:.0}",
|
||||
"Add {local_n} to My Photos": "將 {local_n} 張加入我的照片",
|
||||
"Add {} Photo{}": "新增 {} 張照片{:.0}",
|
||||
"Add “{}” to Favorites": "將「{}」加入喜好項目",
|
||||
"Added {n} photo{} to My Photos": "已將 {n} 張照片加入我的照片{:.0}",
|
||||
"Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}",
|
||||
"Adding photos… {} of {}": "正在新增照片… {}/{}",
|
||||
"All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。",
|
||||
"Auto ({})": "自動({})",
|
||||
"Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片",
|
||||
"Automatic ({} MB)": "自動({} MB)",
|
||||
"Build 1:1 Previews ({scope})": "建立 1:1 預覽({scope})",
|
||||
"Build Smart Previews ({scope})": "建立智慧型預覽({scope})",
|
||||
"Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})",
|
||||
"Click {}:1": "點擊時 {}:1",
|
||||
"Create {n} Virtual Copies": "建立 {n} 張虛擬副本",
|
||||
"Decoder: {reason}": "解碼器:{reason}",
|
||||
"Delete \"{label}\"": "刪除「{label}」",
|
||||
"Delete \"{label}\" (and the mask)": "刪除「{label}」及遮色片",
|
||||
"Delete {n} Photos": "刪除 {n} 張照片",
|
||||
"Delete “{current}”": "刪除「{current}」",
|
||||
"Discard Smart Previews ({scope})": "捨棄智慧型預覽({scope})",
|
||||
"Duplicate \"{label}\"": "複製「{label}」",
|
||||
"Example: {example}": "範例:{example}",
|
||||
"Exported {n} photo{}": "已匯出 {n} 張照片{:.0}",
|
||||
"Exported {ok} of {total} photo{}": "已匯出 {ok}/{total} 張照片{:.0}",
|
||||
"Exported {} point curve preset{}": "已匯出 {} 個點曲線預設集{:.0}",
|
||||
"Exported {} preset{}": "已匯出 {} 個預設集{:.0}",
|
||||
"Exporting {} of {total}": "正在匯出第 {}/{total} 張",
|
||||
"Filter bar — {active} active filter{}": "篩選列 — 已套用 {active} 個篩選條件{:.0}",
|
||||
"Healed {n} dust spot{}": "已修復 {n} 個灰塵斑點{:.0}",
|
||||
"Import cancelled · {} photo{} added": "已取消匯入 · 已加入 {} 張照片{:.0}",
|
||||
"Imported {n} point curve preset{}": "已匯入 {n} 個點曲線預設集{:.0}",
|
||||
"Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}",
|
||||
"Imported {} photo{}": "已匯入 {} 張照片{:.0}",
|
||||
"Label Set: {name}": "標籤集:{name}",
|
||||
"Matches: {}": "符合條件:{}",
|
||||
"Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%",
|
||||
"Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?",
|
||||
"Named ({named_n})": "已命名({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定",
|
||||
"Preset: {name}": "預設集:{name}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "僅供預覽 — 正在編輯相機內嵌的 JPEG({})",
|
||||
"Preview only — {}": "僅供預覽 — {}",
|
||||
"Profile: {}": "描述檔:{}",
|
||||
"Proof Copy ({})": "校樣副本({})",
|
||||
"Proof Preview · {}": "校樣預覽 · {}",
|
||||
"Quick Develop · {n} photos": "快速顯像 · {n} 張照片",
|
||||
"Quick Develop: {name}": "快速顯像:{name}",
|
||||
"Rated {}": "評分:{}",
|
||||
"Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}",
|
||||
"Reading photos… {} of {}": "正在讀取照片… {}/{}",
|
||||
"Redo {}": "重做 {}",
|
||||
"Remove “{}” from Favorites": "將「{}」從喜好項目移除",
|
||||
"Rename {n} Photos…": "重新命名 {n} 張照片…",
|
||||
"Rename “{name}”": "重新命名「{name}」",
|
||||
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
|
||||
"Reset {name}": "重設{name}",
|
||||
"Reset {} Channel": "重設 {} 通道",
|
||||
"Saved export preset “{}”": "已儲存匯出預設集「{}」",
|
||||
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
|
||||
"Showing {n} of {}": "顯示 {n}/{} 張",
|
||||
"Smart album: {}": "智慧型相簿:{}",
|
||||
"Undo {}": "復原 {}",
|
||||
"Updated “{name}”": "已更新「{name}」",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。",
|
||||
"Version: {}": "版本:{}",
|
||||
"none (CPU rendering): {why}": "無(使用 CPU 算繪):{why}",
|
||||
"the {n} selected photos": "選取的 {n} 張照片",
|
||||
"the {} photos in view": "目前顯示的 {} 張照片",
|
||||
"these {count} photos": "這 {count} 張照片",
|
||||
"{count} smart preview{} · {:.1} MB": "{count} 張智慧型預覽{:.0} · {:.1} MB",
|
||||
"{f} folders (and their subfolders)": "{f} 個資料夾(及其子資料夾)",
|
||||
"{label} Grading": "{label}色彩分級",
|
||||
"{label} {d:+} on every selected photo": "每張選取照片的{label}調整 {d:+}",
|
||||
"{label}, {n} photos": "{label},{n} 張照片",
|
||||
"{matching} of {t} photos": "{matching}/{t} 張照片",
|
||||
"{matching} photos": "{matching} 張照片",
|
||||
"{mode} spot {} of {}": "{mode}斑點:第 {}/{} 個",
|
||||
"{month}": "{month}月",
|
||||
"{month} {year}": "{year}年{month}月",
|
||||
"{msg} ({failed} failed)": "{msg}({failed} 個失敗)",
|
||||
"{name} Label": "{name}標籤",
|
||||
"{name} Off": "關閉{name}",
|
||||
"{name} On": "開啟{name}",
|
||||
"{name} — double-click to reset it": "{name} — 按兩下可重設",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 項變更{:.0}已保留在記憶體,但尚未寫入磁碟:{e}\nLightCraft 會自動重試;現在結束程式會遺失{}。",
|
||||
"{n} found · {sel} selected": "找到 {n} 張 · 已選取 {sel} 張",
|
||||
"{n} photos": "{n} 張照片",
|
||||
"{n} photos in {} are hidden by the filters above": "{}中的 {n} 張照片被上方篩選條件隱藏",
|
||||
"{n} photo{}": "{n} 張照片{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} 張照片符合{:.0} · 隨照片變更自動更新",
|
||||
"{n} photo{} now · updates automatically as photos change": "目前 {n} 張照片{:.0} · 隨照片變更自動更新",
|
||||
"{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
"{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}",
|
||||
"{weekday}, {day}": "{day}日 {weekday}",
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
|
||||
"{what} merge added": "已新增{what}合併照片",
|
||||
"{what} merge failed: {e}": "{what}合併失敗:{e}",
|
||||
"{year}": "{year}年",
|
||||
"{} and higher": "{} 以上",
|
||||
"{} files": "{} 個檔案",
|
||||
"{} files and {f} folder{}": "{} 個檔案與 {f} 個資料夾{:.0}",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "已繪製 {}/4 條參考線 — 沿應為垂直或水平的線條拖曳。",
|
||||
"{} photos": "{} 張照片",
|
||||
"{} photo{} not readable": "{} 張照片無法讀取{:.0}",
|
||||
"{} spot(s) on this photo": "此照片有 {} 個斑點",
|
||||
"· {dups} already in the library": "· {dups} 張已在照片庫中",
|
||||
"· {n} photo{}": "· {n} 張照片{:.0}",
|
||||
"… and {more} more": "…以及另外 {more} 個",
|
||||
" ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)",
|
||||
"Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -267,6 +267,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
u.dialog = app.ui.dialog.clone();
|
||||
u.status = app.ui.status.clone();
|
||||
app.ui = u;
|
||||
crate::i18n::set_language(app.ui.language);
|
||||
ctx.request_repaint();
|
||||
ok(serde_json::to_value(&app.ui).unwrap_or_default())
|
||||
}
|
||||
|
||||
+215
-19
@@ -118,6 +118,7 @@ macro_rules! language_table {
|
||||
language_table! {
|
||||
En, "en";
|
||||
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
|
||||
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
|
||||
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
|
||||
}
|
||||
|
||||
@@ -183,6 +184,93 @@ fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> {
|
||||
// `locales/*-formats.json`.
|
||||
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
|
||||
|
||||
/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
|
||||
/// exactly as its owner wrote it.
|
||||
pub fn builtin_label(source: &str, builtin: bool) -> &str {
|
||||
if builtin { tr(source) } else { source }
|
||||
}
|
||||
|
||||
/// An Activity (history) step for display. The step's stored label stays English; generated steps
|
||||
/// are translated around the names they carry, and a user preset's name is kept verbatim.
|
||||
pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String {
|
||||
if let Some(name) = source.strip_prefix("Preset: ") {
|
||||
// A user preset may share a stock preset's name: then the step could be either, so it
|
||||
// keeps the name as written.
|
||||
let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name);
|
||||
return tr_format!("Preset: {name}", name = builtin_label(name, builtin));
|
||||
}
|
||||
if let Some(name) = source.strip_prefix("Reset ") {
|
||||
return tr_format!("Reset {name}", name = tr(name));
|
||||
}
|
||||
if let Some(name) = source.strip_prefix("Quick Develop: ") {
|
||||
return tr_format!("Quick Develop: {name}", name = tr(name));
|
||||
}
|
||||
tr(source).to_string()
|
||||
}
|
||||
|
||||
/// A library source's heading (All Photos, Recently Deleted…) in the UI language; an album's
|
||||
/// name is the user's and stays verbatim.
|
||||
pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String {
|
||||
let label = source.label(catalog);
|
||||
if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() }
|
||||
}
|
||||
|
||||
/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full
|
||||
/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the
|
||||
/// catalog's own wording; every other language formats it with its `*-formats.json` date
|
||||
/// patterns and weekday names. The grouping keys themselves never change.
|
||||
pub fn date_group_label(key: &str, short: bool) -> String {
|
||||
let label = lightcraft_catalog::dates::group_label(key);
|
||||
if language() == Locale::En {
|
||||
return if short {
|
||||
match key.len() {
|
||||
// "September 2026" → "September"
|
||||
7 => label.split(' ').next().unwrap_or(&label).to_string(),
|
||||
// "Sunday, 20 September 2026" → "Sunday, 20"
|
||||
10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(),
|
||||
_ => label,
|
||||
}
|
||||
} else {
|
||||
label
|
||||
};
|
||||
}
|
||||
let Some(year) = key.get(..4).and_then(|y| y.parse::<u32>().ok()) else { return tr(&label).to_string() };
|
||||
if key.len() == 4 {
|
||||
return tr_format!("{year}", year = year);
|
||||
}
|
||||
let Some(month) = key.get(5..7).and_then(|m| m.parse::<u32>().ok()).filter(|m| (1..=12).contains(m)) else {
|
||||
return tr(&label).to_string();
|
||||
};
|
||||
if key.len() == 7 {
|
||||
return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) };
|
||||
}
|
||||
if key.len() == 10
|
||||
&& let Some(weekday) = lightcraft_catalog::dates::weekday(key)
|
||||
&& let Some(day) = key.get(8..10).and_then(|d| d.parse::<u32>().ok())
|
||||
{
|
||||
let weekday = tr(weekday);
|
||||
return if short {
|
||||
tr_format!("{weekday}, {day}", weekday = weekday, day = day)
|
||||
} else {
|
||||
tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year)
|
||||
};
|
||||
}
|
||||
tr(&label).to_string()
|
||||
}
|
||||
|
||||
/// A capture time for display in the UI language (English: "March 30, 2022 at 10:11:11 PM");
|
||||
/// the metadata itself is never rewritten, and a value that doesn't parse is shown as it is.
|
||||
pub fn display_time(iso: &str) -> String {
|
||||
if language() == Locale::En || lightcraft_catalog::dates::normalize_iso(iso).is_none() {
|
||||
return lightcraft_catalog::dates::display_time(iso);
|
||||
}
|
||||
let date = date_group_label(iso.get(..10).unwrap_or(iso), false);
|
||||
match iso.get(11..).filter(|time| !time.is_empty()) {
|
||||
Some(time) => format!("{date} {time}"),
|
||||
None => date,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -205,10 +293,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalogs of the translations agree: same keys, same format placeholders. A source string
|
||||
/// added to one language but forgotten in another fails here.
|
||||
/// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover
|
||||
/// different sets of messages (a message a language lacks shows in English): the gaps are
|
||||
/// reported, not failed, so a translation can grow at its own pace.
|
||||
#[test]
|
||||
fn catalogs_agree_on_keys_and_placeholders() {
|
||||
fn catalogs_agree_on_placeholders_and_report_gaps() {
|
||||
let fields = |text: &str| {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut rest = text;
|
||||
@@ -221,18 +310,15 @@ mod tests {
|
||||
out
|
||||
};
|
||||
let translated: Vec<Locale> = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect();
|
||||
let Some((first, rest)) = translated.split_first() else { return };
|
||||
for language in rest {
|
||||
let (a, b) = (first.catalog(), language.catalog());
|
||||
for (key, value) in a {
|
||||
let Some(other) = b.get(key) else {
|
||||
panic!("{language:?} is missing a translation for {key:?}");
|
||||
};
|
||||
assert_eq!(fields(key), fields(value), "{first:?}: {key:?} -> {value:?}");
|
||||
assert_eq!(fields(key), fields(other), "{language:?}: {key:?} -> {other:?}");
|
||||
let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect();
|
||||
for language in &translated {
|
||||
let catalog = language.catalog();
|
||||
for (key, value) in catalog {
|
||||
assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}");
|
||||
}
|
||||
for key in b.keys() {
|
||||
assert!(a.contains_key(key), "{language:?} translates {key:?}, which {first:?} does not");
|
||||
let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect();
|
||||
if !missing.is_empty() {
|
||||
eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,6 +331,12 @@ mod tests {
|
||||
assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant));
|
||||
assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant));
|
||||
assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant));
|
||||
assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant));
|
||||
assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant));
|
||||
assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant));
|
||||
assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja));
|
||||
assert_eq!(Locale::parse_tag("de"), None);
|
||||
@@ -313,7 +405,7 @@ mod tests {
|
||||
// The faces this language's script needs; a language the craft-fonts input does not
|
||||
// cover (a translation added ahead of its font) is reported, not failed.
|
||||
let mut faces: Vec<&str> =
|
||||
lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.covers(language.script())).map(|font| font.family).collect();
|
||||
lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect();
|
||||
faces.dedup();
|
||||
if faces.is_empty() {
|
||||
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
|
||||
@@ -360,8 +452,12 @@ mod tests {
|
||||
/// The language menu covers every language, and a language's own command selects it.
|
||||
#[test]
|
||||
fn language_commands_cover_every_language() {
|
||||
let commands =
|
||||
[("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja)];
|
||||
let commands = [
|
||||
("app.language.traditionalChinese", Locale::ZhHant),
|
||||
("app.language.english", Locale::En),
|
||||
("app.language.simplifiedChinese", Locale::ZhHans),
|
||||
("app.language.japanese", Locale::Ja),
|
||||
];
|
||||
// One command per language, and every command reachable from the menu table.
|
||||
assert_eq!(commands.len(), Locale::ALL.len());
|
||||
for (id, language) in commands {
|
||||
@@ -479,9 +575,9 @@ mod tests {
|
||||
/// family, Monospace included.
|
||||
#[test]
|
||||
fn craft_fonts_render_cjk_in_the_ui() {
|
||||
let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字")];
|
||||
let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")];
|
||||
for (language, sample) in samples {
|
||||
if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.covers(language.script())) {
|
||||
if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) {
|
||||
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
|
||||
continue;
|
||||
}
|
||||
@@ -531,4 +627,104 @@ mod tests {
|
||||
}
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
/// Traditional Chinese through the menu command and the control channel, persisted, with
|
||||
/// localised headings and dates; ids and user text are untouched.
|
||||
#[test]
|
||||
fn traditional_chinese_switches_through_menu_and_control_and_persists() {
|
||||
use crate::control::{ControlRequest, Outcome};
|
||||
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap();
|
||||
assert_eq!(app.ui.language, Locale::ZhHant);
|
||||
// Applied at once, not on the next frame.
|
||||
assert_eq!(tr("File"), "檔案");
|
||||
assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片");
|
||||
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
|
||||
assert_eq!(date_group_label("2026-09-20", true), "20日 星期日");
|
||||
assert_eq!(date_group_label("2026-09", false), "2026年9月");
|
||||
assert_eq!(date_group_label("2026-09", true), "9月");
|
||||
assert_eq!(date_group_label("2026", false), "2026年");
|
||||
assert_eq!(date_group_label("", false), "未知日期");
|
||||
assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05");
|
||||
assert_eq!(display_time("a-user-value"), "a-user-value");
|
||||
assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true));
|
||||
assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false));
|
||||
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
|
||||
for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] {
|
||||
assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color");
|
||||
}
|
||||
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩");
|
||||
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片");
|
||||
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片");
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
assert!(saved.contains(r#""language":"zh-hant""#), "{saved}");
|
||||
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::ZhHant);
|
||||
let ctx = egui::Context::default();
|
||||
app.ui.language = Locale::En;
|
||||
for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] {
|
||||
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
|
||||
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") };
|
||||
assert_eq!(reply["ok"], true, "{code}");
|
||||
assert_eq!(app.ui.language, Locale::ZhHant, "{code}");
|
||||
assert_eq!(language(), Locale::ZhHant, "{code} applies at once");
|
||||
}
|
||||
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"}));
|
||||
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") };
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert_eq!(app.ui.language, Locale::ZhHant);
|
||||
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
|
||||
let chinese_ids = ids(&app);
|
||||
app.run("app.language.english", serde_json::json!({})).unwrap();
|
||||
assert_eq!(tr("File"), "File");
|
||||
assert_eq!(chinese_ids, ids(&app));
|
||||
}
|
||||
|
||||
/// Dates follow every language's own patterns; English keeps the catalog's wording.
|
||||
#[test]
|
||||
fn date_headings_in_every_language() {
|
||||
set_language(Locale::En);
|
||||
assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026");
|
||||
assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20");
|
||||
assert_eq!(date_group_label("2026-09", true), "September");
|
||||
assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05"));
|
||||
set_language(Locale::Ja);
|
||||
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)");
|
||||
assert_eq!(date_group_label("2026-09", true), "9月");
|
||||
set_language(Locale::ZhHans);
|
||||
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
|
||||
for language in Locale::ALL {
|
||||
set_language(*language);
|
||||
// A key that is not a date never panics and never shows a raw pattern.
|
||||
for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] {
|
||||
assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}");
|
||||
assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}");
|
||||
}
|
||||
}
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
/// Stock presets and panel headers are translated; a user preset keeps its name, also when it
|
||||
/// shares a stock preset's name.
|
||||
#[test]
|
||||
fn traditional_chinese_presets_and_panel_headers_are_painted() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
app.ui.presets = true;
|
||||
let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone();
|
||||
app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin });
|
||||
for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] {
|
||||
app.ui.right = panel;
|
||||
let text = painted_text(&ctx, &mut app, Locale::ZhHant);
|
||||
assert!(text.contains(title), "{text}");
|
||||
assert!(!text.contains("History") && !text.contains("Versions"), "{text}");
|
||||
assert!(text.contains("暖光"), "stock preset: {text}");
|
||||
assert!(text.contains("Warm Glow"), "user preset: {text}");
|
||||
assert!(text.contains("色彩"), "stock group: {text}");
|
||||
}
|
||||
assert_eq!(history_label("Exposure", &app.session.presets), "曝光");
|
||||
assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片");
|
||||
assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow");
|
||||
set_language(Locale::En);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static
|
||||
pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
|
||||
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
|
||||
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
|
||||
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
|
||||
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
|
||||
];
|
||||
|
||||
@@ -30,6 +31,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
|
||||
match id {
|
||||
"app.language.english" => Some(crate::i18n::Locale::En),
|
||||
"app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans),
|
||||
"app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
|
||||
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
|
||||
_ => None,
|
||||
}
|
||||
@@ -231,6 +233,9 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
|
||||
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if let Some(language) = language_from_command(id) {
|
||||
app.ui.language = language;
|
||||
// Immediately, not on the next frame: the reply and anything else run this frame
|
||||
// (menus rebuilt from it, toasts) are already in the new language.
|
||||
crate::i18n::set_language(app.ui.language);
|
||||
return Some(Ok(json!(app.ui.language)));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -466,7 +466,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.separator();
|
||||
}
|
||||
builtin = b;
|
||||
if ui.selectable_label(false, &p.name).clicked() {
|
||||
if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() {
|
||||
chosen = Some(p.name);
|
||||
}
|
||||
}
|
||||
@@ -701,7 +701,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::Import { opts } => crate::import::body(app, ui, opts),
|
||||
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
|
||||
Dialog::ConfirmDelete { count } => {
|
||||
let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
|
||||
let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
|
||||
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
|
||||
}
|
||||
@@ -710,7 +710,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
|
||||
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
|
||||
ui.add_space(10.0);
|
||||
let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE))
|
||||
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
|
||||
.fill(t.accent)
|
||||
.min_size(egui::vec2(260.0, 34.0));
|
||||
let r = ui.add(discord).on_hover_text(crate::links::DISCORD);
|
||||
@@ -724,7 +724,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
("Source code on GitHub", crate::links::GITHUB),
|
||||
("ArtCraft — more creative apps", crate::links::WEBSITE),
|
||||
] {
|
||||
let r = ui.link(label).on_hover_text(url);
|
||||
let r = ui.link(crate::i18n::tr(label)).on_hover_text(url);
|
||||
if r.clicked() {
|
||||
let _ = crate::links::open(app, url);
|
||||
}
|
||||
@@ -775,7 +775,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::Import { opts } => {
|
||||
let n = opts.selected_paths().len();
|
||||
let verb = if opts.copy && opts.move_files { "Move" } else { "Import" };
|
||||
add_label = format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" });
|
||||
add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n);
|
||||
add_label.as_str()
|
||||
}
|
||||
Dialog::Merge { .. } => "Merge",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
groups.entry(p.group.clone()).or_default().push(p.clone());
|
||||
}
|
||||
for (g, items) in groups {
|
||||
// A group holding stock presets is a stock group (shown in the UI language), even
|
||||
// when user presets share its name; a group of user presets keeps its name.
|
||||
let builtin = items.iter().any(|p| p.builtin);
|
||||
let open_id = egui::Id::new(("preset-group", &g));
|
||||
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||
@@ -95,7 +98,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if open { Icon::ChevronDown } else { Icon::ChevronRight },
|
||||
t.text_label,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, &g, t.semibold(13.0), t.text_label);
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 36.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::builtin_label(&g, builtin),
|
||||
t.semibold(13.0),
|
||||
t.text_label,
|
||||
);
|
||||
if resp.clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(open_id, !open));
|
||||
}
|
||||
@@ -108,7 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
continue;
|
||||
}
|
||||
for pr in items {
|
||||
let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite);
|
||||
let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
|
||||
register(ui.ctx(), format!("preset:{pid}"), r);
|
||||
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
|
||||
@@ -156,7 +165,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let (builtin, group) = (pr.builtin, pr.group.clone());
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
|
||||
if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
|
||||
let _ = app.run("preset.favorite", json!({"id": pid}));
|
||||
}
|
||||
if builtin {
|
||||
@@ -168,7 +177,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
.clicked()
|
||||
{
|
||||
match app.run("preset.update", json!({"id": pid})) {
|
||||
Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")),
|
||||
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
|
||||
/// "Sep 30, 2026, 12:00 PM" from an ISO time.
|
||||
fn short_time(iso: &str) -> String {
|
||||
// Other languages use their own date patterns (`i18n::display_time`).
|
||||
if crate::i18n::language() != crate::i18n::Locale::En {
|
||||
return crate::i18n::display_time(iso);
|
||||
}
|
||||
let long = lightcraft_catalog::dates::display_time(iso);
|
||||
// "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped
|
||||
let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t)));
|
||||
@@ -935,7 +940,13 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon);
|
||||
ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, &h.label, t.font(12.5), t.text_label);
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 24.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::history_label(&h.label, &app.session.presets),
|
||||
t.font(12.5),
|
||||
t.text_label,
|
||||
);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("history.restore", json!({"index": i}));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ LightCraft 的界面支持简体中文、日语和英语。语言设置保存在
|
||||
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
|
||||
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
|
||||
原生桌面版不受影响。
|
||||
- 日期分组标题(如 `Sunday, 20 September 2026`)沿用英文格式,尚未本地化。
|
||||
- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
|
||||
|
||||
## 翻译的维护
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# LightCraft 繁體中文(台灣)介面
|
||||
|
||||
從「編輯 → 語言 → 繁體中文(台灣)」切換,或開啟「設定 → 一般 → 語言」選擇。
|
||||
macOS 的設定也可由「LightCraft → 設定…」或 `⌘,` 開啟。
|
||||
語言立即生效,並儲存在應用程式設定的 `ui.json`,下次啟動會沿用。
|
||||
英文、簡體中文、繁體中文與日文可隨時互相切換。
|
||||
|
||||
繁中翻譯涵蓋 macOS 原生選單、程式內選單與子選單、鍵盤快速鍵說明、照片編輯、
|
||||
裁切、遮色片、匯入/匯出、設定、41 個內建編輯預設集與分類、內建描述檔、
|
||||
曲線與匯出預設集、歷史紀錄/版本面板、內建照片來源標題、日期群組與拍攝時間、
|
||||
照片庫開啟失敗提示及主要處理進度。
|
||||
切換介面不會變更操作指令 ID、照片檔名、使用者命名的相簿、預設集或中繼資料。
|
||||
未翻譯的技術錯誤、發行說明及部分新增文案仍使用英文。
|
||||
|
||||
## 建置與字型
|
||||
|
||||
使用既有 [craft-fonts](https://github.com/storytold/craft-fonts) 字型作為建置輸入:
|
||||
|
||||
```sh
|
||||
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
```
|
||||
|
||||
craft-fonts 目前沒有繁體中文專用字型:桌面版先以 Noto Sans CJK SC 顯示繁中文字(字形為大陸規範寫法),其次才是日文字型;不在此儲存庫新增字型檔。
|
||||
未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。
|
||||
Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。
|
||||
|
||||
## 自動化與維護
|
||||
|
||||
`LIGHTCRAFT_LANGUAGE=zh-hant lightcraft-cli snapshot --demo -o zh-hant.png` 可繪製繁中畫面。
|
||||
接受 `zh-hant`、`zh-Hant`、`zh-TW`、`zh_TW.UTF-8` 與 `zh-HK`;儲存時統一使用 `zh-hant`。
|
||||
控制通道可切換語言:
|
||||
|
||||
```json
|
||||
{"method":"ui.set","params":{"language":"zh-hant"}}
|
||||
{"method":"ui.menu.invoke","params":{"command":"app.language.traditionalChinese"}}
|
||||
```
|
||||
|
||||
靜態文案位於 `crates/ui-egui/locales/zh-hant.json`,含變數的訊息位於
|
||||
`crates/ui-egui/locales/zh-hant-formats.json`。動態訊息由 Rust 在建置時檢查格式參數,
|
||||
所有 `*-formats.json` 須保持相同英文鍵。新增語言的方法見 [`localization.md`](localization.md)。`{:.0}` 消耗英文複數字尾引數,繁中不顯示該字尾。
|
||||
|
||||
```sh
|
||||
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests
|
||||
```
|
||||
|
||||
測試涵蓋翻譯目錄、指令/選單文字、語言切換、設定保存、資料名稱保持原樣及桌面字形。
|
||||
+11
-5
@@ -1,22 +1,28 @@
|
||||
# Localization
|
||||
|
||||
The interface ships in the language it is written in (English) plus every language in the table in
|
||||
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`) and Japanese
|
||||
(`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
are in [`localization-zh-hans.md`](localization-zh-hans.md) and [`localization-ja.md`](localization-ja.md).
|
||||
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
|
||||
Chinese (`zh-hant`, Taiwan) and Japanese (`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md) and
|
||||
[`localization-ja.md`](localization-ja.md).
|
||||
|
||||
## Adding a language
|
||||
|
||||
1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to
|
||||
`locales/<code>.json` and `locales/<code>-formats.json`, then translate the values. Keys are the
|
||||
English source strings and must stay byte-for-byte identical — `cargo test -p lightcraft-ui-egui
|
||||
i18n::tests` fails on a missing, extra or empty entry.
|
||||
English source strings and must stay byte-for-byte identical. The plain catalog may grow at its
|
||||
own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests
|
||||
-- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The
|
||||
formats catalog must carry every message (the build fails otherwise), including the date
|
||||
patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and
|
||||
capture times use in every language but English; weekday names go in the plain catalog.
|
||||
2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`:
|
||||
|
||||
```rust
|
||||
language_table! {
|
||||
En, "en";
|
||||
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
|
||||
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
|
||||
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
|
||||
}
|
||||
```
|
||||
|
||||
+2
-2
@@ -503,8 +503,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
|
||||
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
|
||||
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Technical errors/release notes and date grouping headers remain English, and the web build embeds only the Japanese face. |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
|
||||
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
|
||||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
|
||||
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
|
||||
|
||||
Reference in New Issue
Block a user