diff --git a/ROADMAP.md b/ROADMAP.md index 54c86e81..c790c566 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | | **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; no localisation; 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; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load | ### By kind of user @@ -86,7 +86,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 ✅; accessibility, localisation ⬜) | +| 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 ⬜) | ## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 0971f015..49fd24ff 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -202,7 +202,9 @@ impl PrefsWriter { fn services() -> Services { Services { - pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))), + pick_folder: Some(Box::new(|| { + rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) + })), open_with: Some(Box::new(|path: &str, app: &str| { // spawned, never waited for: the editor runs alongside let app = app.trim(); @@ -254,7 +256,7 @@ fn services() -> Services { pick_files: Some(Box::new(|| { rfd::FileDialog::new() .add_filter( - "Photos", + lightcraft_ui_egui::i18n::tr("Photos"), &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", @@ -268,8 +270,11 @@ fn services() -> Services { })), pick_preset_files: Some(Box::new(|| { rfd::FileDialog::new() - .set_title("Import Presets") - .add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"]) + .set_title(lightcraft_ui_egui::i18n::tr("Import Presets")) + .add_filter( + lightcraft_ui_egui::i18n::tr("Presets & Profiles"), + &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"], + ) .pick_files() .unwrap_or_default() .into_iter() @@ -278,24 +283,24 @@ fn services() -> Services { })), pick_tracklog: Some(Box::new(|| { rfd::FileDialog::new() - .set_title("Auto-Tag from Tracklog") - .add_filter("GPS Track Log", &["gpx"]) + .set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog")) + .add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"]) .pick_file() .map(|p| vec![p.to_string_lossy().to_string()]) .unwrap_or_default() })), save_preset_file: Some(Box::new(|name: &str| { rfd::FileDialog::new() - .set_title("Export Presets") - .add_filter("LightCraft Preset", &["lcpreset"]) + .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) + .add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"]) .set_file_name(name) .save_file() .map(|p| p.to_string_lossy().to_string()) })), pick_curve_preset_files: Some(Box::new(|| { rfd::FileDialog::new() - .set_title("Import Point Curve Presets") - .add_filter("Point Curve Presets", &["lccurve", "json"]) + .set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets")) + .add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"]) .pick_files() .unwrap_or_default() .into_iter() @@ -304,8 +309,8 @@ fn services() -> Services { })), save_curve_preset_file: Some(Box::new(|name: &str| { rfd::FileDialog::new() - .set_title("Export Point Curve Presets") - .add_filter("Point Curve Presets", &["lccurve"]) + .set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets")) + .add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"]) .set_file_name(name) .save_file() .map(|p| p.to_string_lossy().to_string()) @@ -434,6 +439,7 @@ fn main() -> eframe::Result { if let Some(ui) = prefs { app.ui = ui; } + lightcraft_ui_egui::i18n::set_language(app.ui.language); app.integrated_titlebar = cfg!(target_os = "macos"); app.notices.extend(prefs_warning); // what's on disk now: only changes are written diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index c522d756..8af250a5 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -148,7 +148,7 @@ fn yields_to_text(sc: &str) -> bool { /// `&` marks a mnemonic in muda labels. fn label_text(s: &str) -> String { - s.replace('&', "&&") + lightcraft_ui_egui::i18n::tr(s).replace('&', "&&") } fn structure_of(bar: &[(String, Vec)]) -> String { @@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec)]) -> String { out.push(';'); } } - let mut s = String::new(); + let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese()); for (t, items) in bar { s.push_str(t); walk(items, &mut s); @@ -210,30 +210,30 @@ impl NativeMenu { // the application menu let app_menu = Submenu::new("LightCraft", true); - let about = MenuItem::with_id("app.about", "About LightCraft", true, None); - let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY)); - let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q")); + let about = MenuItem::with_id("app.about", lightcraft_ui_egui::i18n::tr("About LightCraft"), true, None); + let settings = MenuItem::with_id(SETTINGS, lightcraft_ui_egui::i18n::tr("Settings…"), true, accelerator(SETTINGS_KEY)); + let quit = MenuItem::with_id(QUIT, lightcraft_ui_egui::i18n::tr("Quit LightCraft"), true, accelerator("Cmd+Q")); let _ = app_menu.append_items(&[ &about, &PredefinedMenuItem::separator(), &settings, &PredefinedMenuItem::separator(), - &PredefinedMenuItem::services(None), + &PredefinedMenuItem::services(Some(lightcraft_ui_egui::i18n::tr("Services"))), &PredefinedMenuItem::separator(), - &PredefinedMenuItem::hide(Some("Hide LightCraft")), - &PredefinedMenuItem::hide_others(None), - &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::hide(Some(lightcraft_ui_egui::i18n::tr("Hide LightCraft"))), + &PredefinedMenuItem::hide_others(Some(lightcraft_ui_egui::i18n::tr("Hide Others"))), + &PredefinedMenuItem::show_all(Some(lightcraft_ui_egui::i18n::tr("Show All"))), &PredefinedMenuItem::separator(), &quit, ]); let _ = self.menu.append(&app_menu); for (title, nodes) in &bar { - let sub = Submenu::new(title, true); - if title == app.ui.language.tr("Window") { + let sub = Submenu::new(label_text(title), true); + if title == "Window" { let _ = sub.append_items(&[ - &PredefinedMenuItem::minimize(None), - &PredefinedMenuItem::maximize(Some("Zoom")), + &PredefinedMenuItem::minimize(Some(lightcraft_ui_egui::i18n::tr("Minimize"))), + &PredefinedMenuItem::maximize(Some(lightcraft_ui_egui::i18n::tr("Zoom"))), &PredefinedMenuItem::separator(), ]); } @@ -245,15 +245,21 @@ impl NativeMenu { .collect(); let nodes = tidy_separators(nodes); self.append_nodes(&sub, &nodes); - if title == app.ui.language.tr("File") { + if title == "File" { // ⌘W, the system's own item - let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]); + let _ = sub.append_items(&[ + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::close_window(Some(lightcraft_ui_egui::i18n::tr("Close Window"))), + ]); } - if title == app.ui.language.tr("Window") { - let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]); + if title == "Window" { + let _ = sub.append_items(&[ + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::bring_all_to_front(Some(lightcraft_ui_egui::i18n::tr("Bring All to Front"))), + ]); sub.set_as_windows_menu_for_nsapp(); } - if title == app.ui.language.tr("Help") { + if title == "Help" { sub.set_as_help_menu_for_nsapp(); } let _ = self.menu.append(&sub); @@ -282,12 +288,23 @@ impl NativeMenu { let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator); let handle = match checked { Some(c) => { - let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel); + let it = CheckMenuItem::with_id( + key.clone(), + lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"), + *enabled, + *c, + accel, + ); let _ = sub.append(&it); Handle::Check(it) } None => { - let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel); + let it = MenuItem::with_id( + key.clone(), + lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"), + *enabled, + accel, + ); let _ = sub.append(&it); Handle::Plain(it) } @@ -325,6 +342,7 @@ impl NativeMenu { /// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus /// accelerators with the app state. pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { + lightcraft_ui_egui::i18n::set_language(app.ui.language); while let Ok(key) = self.rx.try_recv() { if key == QUIT { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -352,8 +370,8 @@ impl NativeMenu { let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue }; if it.label != *label { match &it.handle { - Handle::Plain(h) => h.set_text(label_text(label)), - Handle::Check(h) => h.set_text(label_text(label)), + Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")), + Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")), } it.label = label.clone(); } diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index bc72d5e6..3d2133c5 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -21,6 +21,8 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | +| `assets/fonts/BIZUDPGothic-*.ttf` | BIZ UDPGothic (Regular, Bold) | Morisawa / BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic/tree/18934af56b9c003ca58c54bffbf226848cb11032 | SIL Open Font License 1.1 (`assets/fonts/OFL-BIZUDGothic.txt`) | 2026-10-05 | none | + Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter. diff --git a/assets/fonts/BIZUDPGothic-Bold.ttf b/assets/fonts/BIZUDPGothic-Bold.ttf new file mode 100644 index 00000000..8cfe3455 Binary files /dev/null and b/assets/fonts/BIZUDPGothic-Bold.ttf differ diff --git a/assets/fonts/BIZUDPGothic-Regular.ttf b/assets/fonts/BIZUDPGothic-Regular.ttf new file mode 100644 index 00000000..0753da09 Binary files /dev/null and b/assets/fonts/BIZUDPGothic-Regular.ttf differ diff --git a/assets/fonts/OFL-BIZUDGothic.txt b/assets/fonts/OFL-BIZUDGothic.txt new file mode 100644 index 00000000..4df8604d --- /dev/null +++ b/assets/fonts/OFL-BIZUDGothic.txt @@ -0,0 +1,93 @@ +Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d52e1725..b2047b0d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,3 +25,6 @@ log = { workspace = true } [dev-dependencies] lightcraft-codecs = { workspace = true } + +[build-dependencies] +serde_json = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs new file mode 100644 index 00000000..75c3e03c --- /dev/null +++ b/crates/ui-egui/build.rs @@ -0,0 +1,18 @@ +//! Compile translated format strings so Rust checks both languages' placeholders. +use std::{collections::BTreeMap, env, fs, path::PathBuf}; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=locales/ja-formats.json"); + let messages: BTreeMap = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?; + let mut source = String::from("macro_rules! tr_format {\n"); + for (english, japanese) in messages { + let en = serde_json::to_string(&english)?; + let ja = serde_json::to_string(&japanese)?; + source.push_str(&format!( + "({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n" + )); + } + source.push_str("}\npub(crate) use tr_format;\n"); + fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?; + Ok(()) +} diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json new file mode 100644 index 00000000..9da942f8 --- /dev/null +++ b/crates/ui-egui/locales/ja-formats.json @@ -0,0 +1,104 @@ +{ + " · {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 “{}”": "「{1}」に{n}枚を追加しました{0:.0}", + "All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします", + "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", + "Automatic ({} MB)": "自動({}MB)", + "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}」を削除", + "Duplicate \"{label}\"": "「{label}」を複製", + "Example: {example}": "例:{example}", + "Exported {n} photo{}": "{n}枚を書き出しました{:.0}", + "Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.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}", + "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}", + "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}枚", + "Rated {}": "評価:{}", + "Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚", + "Redo {}": "やり直す:{}", + "Remove “{}” from Favorites": "「{}」をお気に入りから削除", + "Rename {n} Photos…": "{n}枚の写真の名前を変更…", + "Rename “{name}”": "「{name}」の名前を変更", + "Rendering on the CPU: {why}": "CPUで描画:{why}", + "Reset {} Channel": "{}チャンネルをリセット", + "Saved export preset “{}”": "書き出しプリセット「{}」を保存しました", + "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", + "Showing {n} of {}": "{}枚中{n}枚を表示", + "Smart album: {}": "スマートアルバム:{}", + "Undo {}": "取り消す:{}", + "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": "{t}枚中{matching}枚", + "{matching} photos": "{matching}枚", + "{mode} spot {} of {}": "{mode}:{1}か所中{0}か所", + "{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}\n自動で再試行します。終了すると変更が失われます{:.0}", + "{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}%", + "{what} merge added": "{what}合成を追加しました", + "{what} merge failed: {e}": "{what}合成に失敗:{e}", + "{} 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}件", + "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", + "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", + "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚" +} diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json new file mode 100644 index 00000000..4e5c71a5 --- /dev/null +++ b/crates/ui-egui/locales/ja.json @@ -0,0 +1,1021 @@ +{ + "+ Group": "+ グループ", + "+ Rule": "+ 条件", + "0: nothing is rejected.": "0:不採用にしません", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "フィルターで写真が非表示になっています。条件を変更するか、「表示 → フィルターを解除」を選んでください", + "About LightCraft": "LightCraftについて", + "Activity": "処理状況", + "Add": "追加", + "Add Catchlight": "キャッチライトを追加", + "Add Folder to Local": "ローカルにフォルダを追加", + "Add Selected Photos": "選択した写真を追加", + "Add a rule (⌥-click: a group)": "条件を追加(⌥クリックでグループ)", + "Add in place": "現在の場所から追加", + "Add in place: the library references the files where they are; nothing is copied or moved.": "現在の場所のファイルを参照します。コピーや移動はしません", + "Add keyword": "キーワードを追加", + "Add number": "番号を付ける", + "Add to": "追加先", + "Add to Album": "アルバムに追加", + "Add to Favorites": "お気に入りに追加", + "Add to My Photos": "マイフォトに追加", + "Add to Selected Photos": "選択した写真に追加", + "Add/Subtract/Intersect Mask": "マスクを追加/減算/交差", + "Added to photos you import that don't already have it.": "読み込む写真に、この情報がない場合だけ追加します", + "Adding": "追加中", + "After": "編集後", + "After 30 days": "30日後", + "After 7 days": "7日後", + "After 90 days": "90日後", + "After a year": "1年後", + "Album": "アルバム", + "Albums": "アルバム", + "All": "すべて", + "All Metadata": "すべてのメタデータ", + "All Metadata…": "すべてのメタデータ…", + "All Photos": "すべての写真", + "All rights reserved": "すべての権利を留保", + "All types": "すべての形式", + "Alt Text": "代替テキスト", + "Always": "常に", + "Amount": "適用量", + "Aperture": "絞り", + "Application": "アプリケーション", + "Apply Preset": "プリセットを適用", + "Approved": "承認済み", + "Aqua": "アクア", + "Aqua Hue": "アクアの色相", + "Aqua Luminance": "アクアの輝度", + "Aqua Saturation": "アクアの彩度", + "As Shot": "撮影時の設定", + "As shot": "撮影時の設定", + "Ascending": "昇順", + "Aspect": "縦横比", + "Aspect Ratio": "縦横比", + "Assisted Culling": "写真の選別を補助", + "Assisted Culling…": "写真の選別を補助…", + "Auto": "自動", + "Auto Advance": "自動送り", + "Auto Advance Off": "自動送りを無効にする", + "Auto Advance On": "自動送りを有効にする", + "Auto Advance after rating or flagging": "評価やフラグを付けたら次の写真へ移動", + "Auto Advance: move to the next photo after rating or flagging": "評価やフラグを付けたら次の写真へ移動", + "Auto Align": "自動整列", + "Auto Crop": "自動切り抜き", + "Auto Import": "自動読み込み", + "Auto Mask": "自動マスク", + "Auto Settings": "自動設定", + "Auto Sync": "自動同期", + "Auto-Stack by Capture Time": "撮影日時で自動スタック", + "Auto-Stack by Capture Time…": "撮影日時で自動スタック…", + "Auto-Tag from Tracklog": "GPS軌跡から位置情報を付ける", + "Auto-Tag from Tracklog…": "GPS軌跡から位置情報を付ける…", + "Automatic": "自動", + "Automatically Write Changes into XMP": "変更をXMPに自動保存", + "Automatically write changes into XMP sidecars": "変更をXMPサイドカーファイルに自動保存", + "B&W": "白黒", + "B&W Mix": "白黒ミックス", + "B&W Mixer": "白黒ミキサー", + "Back": "戻る", + "Back to Edit": "編集に戻る", + "Back to Edit (E)": "編集に戻る(E)", + "Back to Grid": "グリッドに戻る", + "Background": "背景", + "Balance": "バランス", + "Before": "編集前", + "Before/After (Y)": "編集前/編集後(Y)", + "Before/After Settings": "編集前/編集後の設定", + "Before/After Split": "編集前/編集後の分割表示", + "Before/After Split Top/Bottom": "編集前/編集後の上下分割表示", + "Before/After Top/Bottom": "編集前/編集後の上下表示", + "Bit depth": "ビット深度", + "Black & White": "白黒", + "Blacks": "黒レベル", + "Blending": "ブレンド", + "Blue": "ブルー", + "Blue Hue": "ブルーの色相", + "Blue Luminance": "ブルーの輝度", + "Blue Primary": "ブルー原色", + "Blue Saturation": "ブルーの彩度", + "Blue channel": "ブルーチャンネル", + "Blue/Yellow Fringe": "青/黄のフリンジ", + "Blurry photos below the score are flagged as rejects.": "基準値未満のぼけた写真に不採用フラグを付けます", + "Boundary Warp": "境界の変形", + "Bring All to Front": "すべてを手前に移動", + "Browse Folder…": "フォルダを参照…", + "Browse Profiles": "プロファイルを参照", + "Browse…": "参照…", + "Brush": "ブラシ", + "Brush Stroke": "ブラシ描画", + "Build Previews": "プレビューを作成", + "Build Smart Previews": "スマートプレビューを作成", + "By Date": "日付別", + "By day (YYYY/YYYY-MM-DD)": "日別(YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "月別(YYYY/YYYY-MM)", + "Calibration": "キャリブレーション", + "Camera": "カメラ", + "Cameras and Cards": "カメラとメモリーカード", + "Cancel": "キャンセル", + "Candidate": "候補", + "Caption": "キャプション", + "Capture Date": "撮影日時", + "Captured": "撮影日時", + "Check All": "すべて選択", + "Choose Edit Settings to Copy": "コピーする編集設定を選択", + "Choose Edit Settings to Copy…": "コピーする編集設定を選択…", + "Choose Folder…": "フォルダを選択…", + "Choose a photo in the grid or filmstrip": "グリッドまたはフィルムストリップから写真を選択してください", + "Choose…": "選択…", + "City": "市区町村", + "Clarity": "明瞭度", + "Clear": "消去", + "Clear Cache": "キャッシュを消去", + "Clear Filters": "フィルターを解除", + "Clear Guides": "ガイドを消去", + "Clear History": "履歴を消去", + "Clear Quick Collection": "クイックコレクションを空にする", + "Clear Reference": "参照写真を解除", + "Clear all": "すべて解除", + "Click Zoom Ratio": "クリック時の拡大率", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "タグをクリックすると挿入できます。例は2026年1月14日05:58:48撮影のIMG_0042.CR3です", + "Click photos in the grid to toggle the keyword; Esc stops": "グリッドの写真をクリックしてキーワードを付け外し(Escで終了)", + "Click the photo to pick a colour · ⇧-click adds more": "写真をクリックして色を選択(⇧クリックで追加)", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "写真をクリックして色を選択(⇧クリックで5色まで追加)", + "Clone": "コピースタンプ", + "Close": "閉じる", + "Close Panel": "パネルを閉じる", + "Close Window": "ウィンドウを閉じる", + "Cloudy": "曇り", + "Collapse All Stacks": "すべてのスタックを閉じる", + "Color": "カラー", + "Color Grading": "カラーグレーディング", + "Color Mixer": "カラーミキサー", + "Color Noise Reduction": "カラーノイズ軽減", + "Color Priority": "カラー優先", + "Color Range": "色範囲", + "Color space": "色空間", + "Color space: sRGB (AVIF)": "色空間:sRGB(AVIF)", + "Compare": "比較", + "Compare (Shift+C)": "比較(⇧C)", + "Compare Before and After": "編集前と編集後を比較", + "Compare needs at least two photos": "比較には2枚以上の写真が必要です", + "Component options": "構成要素の設定", + "Compression": "圧縮", + "Confirm before deleting": "削除前に確認", + "Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認", + "Constrain Crop": "切り抜きを制限", + "Contrast": "コントラスト", + "Copy": "コピー", + "Copy After's Settings to Before": "編集後の設定を編集前にコピー", + "Copy Before's Settings to After": "編集前の設定を編集後にコピー", + "Copy Edit Settings": "編集設定をコピー", + "Copy History Step Settings to Before": "履歴の設定を編集前にコピー", + "Copy Metadata": "メタデータをコピー", + "Copy as DNG": "DNGとしてコピー", + "Copy into the library (else use the files where they are)": "ライブラリ内にコピー(オフの場合は現在の場所を参照)", + "Copy the files (into the library's Originals/, or a folder you choose)": "ライブラリのOriginalsフォルダまたは指定フォルダにコピー", + "Copy to": "コピー先", + "Copy to Clipboard": "クリップボードにコピー", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "下のフォルダへコピーし、そのコピーを使用します。元のファイルはそのまま残します", + "Copyright": "著作権", + "Copyright Info URL": "著作権情報のURL", + "Copyright Status": "著作権の状態", + "Country": "国", + "Create Album": "アルバムを作成", + "Create Album…": "アルバムを作成…", + "Create Folder": "フォルダを作成", + "Create Folder…": "フォルダを作成…", + "Create New Mask": "新しいマスクを作成", + "Create Preset": "プリセットを作成", + "Create Preset…": "プリセットを作成…", + "Create Proof Copy": "校正用コピーを作成", + "Create Smart Album": "スマートアルバムを作成", + "Create Smart Album from Filter…": "現在のフィルターからスマートアルバムを作成…", + "Create Smart Album…": "スマートアルバムを作成…", + "Create Stack": "スタックを作成", + "Create Version": "バージョンを作成", + "Create Version from Step": "この履歴からバージョンを作成", + "Create Virtual Copy": "仮想コピーを作成", + "Creator": "作成者", + "Crop": "切り抜き", + "Crop & Rotate": "切り抜きと回転", + "Crop & Rotate (C)": "切り抜きと回転(C)", + "Culling": "写真の選別", + "Curve": "カーブ", + "Custom": "カスタム", + "Cylindrical": "円筒", + "Darken": "暗くする", + "Darks": "暗部", + "Date Groups": "日付グループ", + "Day": "日", + "Daylight": "昼光", + "Defringe": "フリンジ軽減", + "Deghost Amount": "ゴースト除去量", + "Dehaze": "かすみの除去", + "Delete": "削除", + "Delete (⌫)": "削除(⌫)", + "Delete Album": "アルバムを削除", + "Delete All Masks": "すべてのマスクを削除", + "Delete Keyword": "キーワードを削除", + "Delete Mask": "マスクを削除", + "Delete Permanently": "完全に削除", + "Delete Photo": "写真を削除", + "Delete Photos": "写真を削除", + "Delete Preset": "プリセットを削除", + "Delete Version": "バージョンを削除", + "Delete all": "すべて削除", + "Delete all spots": "すべてのスポットを削除", + "Delete mask": "マスクを削除", + "Delete them": "削除する", + "Density": "密度", + "Depth Range": "深度範囲", + "Descending": "降順", + "Deselect All": "選択を解除", + "Detail": "詳細", + "Detail (D)": "詳細(D)", + "Diagonal": "対角線", + "Discard Preview Cache": "プレビューキャッシュを破棄", + "Display P3": "Display P3", + "Distortion": "ゆがみ", + "Don't enlarge": "拡大しない", + "Done": "完了", + "Drag along the horizon; double-click for Auto": "水平線に沿ってドラッグ(ダブルクリックで自動補正)", + "Drag over an eye on the photo; the pupil inside is found automatically.": "写真の目をドラッグすると、瞳孔を自動検出します", + "Draw Guides": "ガイドを描く", + "Duplicate": "複製", + "Duplicate Mask": "マスクを複製", + "Duplicate and Invert Mask": "マスクを複製して反転", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "「/」でフォルダ階層を区切ります。タグは写真ごとに展開し、情報がない階層は「unknown」になります", + "Edit": "編集", + "Edit (E)": "編集(E)", + "Edit Capture Time": "撮影日時を変更", + "Edit Capture Time…": "撮影日時を変更…", + "Edit Color Label Names": "カラーラベル名を編集", + "Edit Label Names…": "ラベル名を編集…", + "Edit Sections": "編集項目", + "Edit Smart Album": "スマートアルバムを編集", + "Edit Smart Album…": "スマートアルバムを編集…", + "Edit in External Editor": "外部エディターで編集", + "Edited": "編集済み", + "Effects": "効果", + "Enable Lens Corrections": "レンズ補正を有効にする", + "Enter Full Screen": "全画面表示", + "Erase": "消去", + "Every EXIF, GPS and XMP field in the file": "ファイル内のすべてのEXIF・GPS・XMP情報", + "Existing previews": "既存のプレビュー", + "Expand All Stacks": "すべてのスタックを展開", + "Export": "書き出し", + "Export Album with Preset": "プリセットでアルバムを書き出す", + "Export Album…": "アルバムを書き出す…", + "Export Group…": "グループを書き出す…", + "Export Now": "今すぐ書き出す", + "Export Point Curve Presets": "ポイントカーブのプリセットを書き出す", + "Export Presets": "プリセットを書き出す", + "Export Presets…": "プリセットを書き出す…", + "Export User Presets…": "ユーザープリセットを書き出す…", + "Export with Preset": "プリセットで書き出す", + "Export with Previous": "前回の設定で書き出す", + "Exporting": "書き出し中", + "Export…": "書き出す…", + "Exposure": "露出", + "Exposure (shutter · aperture · ISO)": "露出(シャッター速度・絞り・ISO)", + "Extended Description": "詳しい説明", + "External Editor": "外部エディター", + "Favorites": "お気に入り", + "Feather": "ぼかし", + "File": "ファイル", + "File & date": "ファイルと日付", + "File Name": "ファイル名", + "File Path": "ファイルの場所", + "File Size": "ファイルサイズ", + "File name": "ファイル名", + "Fill Edges": "境界を補完", + "Filmstrip": "フィルムストリップ", + "Filter": "フィルター", + "Filter Bar": "フィルターバー", + "Filter Presets": "フィルタープリセット", + "Filter bar": "フィルターバー", + "Filter fields": "項目を絞り込む", + "Find Dust Spots": "ほこりを検出", + "Find Missing Photos…": "見つからない写真を検索…", + "Find Similar Photos": "似た写真を検索", + "Fit": "全体を表示", + "Fit (F)": "全体表示(F)", + "Flag": "フラグ", + "Flag as Pick": "採用フラグを付ける", + "Flag as Reject": "不採用フラグを付ける", + "Flash": "フラッシュ", + "Flip H": "左右反転", + "Flip Horizontal": "左右反転", + "Flip Overlay (⇧O)": "重ね表示の向きを変更(⇧O)", + "Flip V": "上下反転", + "Flip Vertical": "上下反転", + "Flow": "流量", + "Fluorescent": "蛍光灯", + "Focal length": "焦点距離", + "Folder": "フォルダ", + "Folder name (renamed on disk; its photos follow)": "フォルダ名(ディスク上で変更し、写真の参照先も更新)", + "Folders": "フォルダ", + "Folders and {date} use the capture time; a photo without one uses today's date.": "フォルダと{date}には撮影日を使います。撮影日がない写真には今日の日付を使います", + "Forget unchanged photos": "変更のない写真をカタログから外す", + "Format": "形式", + "Forward": "進む", + "Free": "自由", + "Full": "全体", + "Full Screen Preview": "全画面プレビュー", + "Full size": "元のサイズ", + "GPS Track Log": "GPS軌跡ファイル", + "General": "一般", + "Geometry": "ジオメトリ", + "Global Hue": "全体の色相", + "Global Luminance": "全体の輝度", + "Global Saturation": "全体の彩度", + "Glossy": "光沢紙", + "Golden": "黄金比", + "Grain": "粒子", + "Graphic": "画像", + "Green": "グリーン", + "Green Amount": "グリーンの適用量", + "Green Hue": "グリーンの色相", + "Green Hue High": "グリーンの色相上限", + "Green Hue Low": "グリーンの色相下限", + "Green Luminance": "グリーンの輝度", + "Green Primary": "グリーン原色", + "Green Saturation": "グリーンの彩度", + "Green channel": "グリーンチャンネル", + "Grid": "グリッド", + "Grid Info": "グリッドの情報", + "Group": "グループ", + "Group by Day": "日別", + "Group by Month": "月別", + "Group by Year": "年別", + "Group by date": "日付でグループ化", + "Group into Stack": "スタックにまとめる", + "Guided": "ガイド付き", + "Guided Upright": "ガイド付き遠近補正", + "HDR Merge": "HDR合成", + "HDR Panorama Merge": "HDRパノラマ合成", + "HDR Panorama…": "HDRパノラマ合成…", + "HDR…": "HDR合成…", + "Heal": "修復", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "センサーのほこりによる小さくぼんやりした暗い点を修復", + "Height": "高さ", + "Height (px)": "高さ(px)", + "Help": "ヘルプ", + "Hide LightCraft": "LightCraftを隠す", + "Hide Mask": "マスクを隠す", + "Hide Others": "ほかを隠す", + "Hides this shortcut only; the folder and its photos stay as they are": "この表示だけを隠します。フォルダと写真はそのまま残ります", + "High": "強", + "Highlight": "ハイライト", + "Highlights": "ハイライト", + "Highlights Hue": "ハイライトの色相", + "Highlights Luminance": "ハイライトの輝度", + "Highlights Saturation": "ハイライトの彩度", + "Highlights split": "ハイライトの境界", + "Histogram": "ヒストグラム", + "History": "履歴", + "History & Activity (Y)": "履歴と処理状況(Y)", + "Horizontal": "水平", + "Hue": "色相", + "Hue Range": "色相の範囲", + "If file exists": "同名ファイルがある場合", + "Import": "読み込み", + "Import Date": "読み込み日時", + "Import Photos": "写真を読み込む", + "Import Photos…": "写真を読み込む…", + "Import Point Curve Presets": "ポイントカーブのプリセットを読み込む", + "Import Presets": "プリセットを読み込む", + "Import Presets…": "プリセットを読み込む…", + "Import Profiles & Presets…": "プロファイルとプリセットを読み込む…", + "Import from Device": "デバイスから読み込む", + "Import from Folder…": "フォルダから読み込む…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "「ファイル → 写真を読み込む…」(⌘⇧I)から読み込むか、ここに写真をドロップしてください", + "Imported Photos": "写真を読み込み", + "Importing": "読み込み中", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "読み込みでは写真を検索しますが、フォルダはローカルに追加されません。読み込まずに作業する場合は「ローカル → フォルダを参照…」を使ってください", + "In library": "ライブラリ内", + "In memory — nothing is saved": "メモリ上のみ(保存されません)", + "In use": "使用中", + "In-memory session: these settings last until LightCraft quits.": "メモリ上のセッション:設定はLightCraftの終了まで有効です", + "Include subfolders": "サブフォルダを含める", + "Info": "情報", + "Info (I)": "情報(I)", + "Info overlay": "情報の重ね表示", + "Interface": "インターフェース", + "Intersect": "交差", + "Intersect with": "交差させる", + "Into one folder": "1つのフォルダにまとめる", + "Invert": "反転", + "Invert Mask": "マスクを反転", + "Keep in Local": "ローカルに残す", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "スマートプレビューは1枚あたり約1MBです。空き容量のあるドライブを指定してください。サムネイルキャッシュはライブラリに残ります", + "Keyboard Shortcuts": "キーボードショートカット", + "Keyboard shortcuts": "キーボードショートカット", + "Keyword": "キーワード", + "Keyword Painter": "キーワード付けツール", + "Keyword Set": "キーワードセット", + "Keyword Sets": "キーワードセット", + "Keyword Suggestions": "キーワード候補", + "Keyword to paint": "写真に付けるキーワード", + "Keywords": "キーワード", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "追加したキーワードはここに表示されます(⌥1〜⌥9で適用)", + "Landscape": "風景", + "Language": "言語", + "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "詳細表示の長辺の上限です。大きいほど鮮明になりますが、描画に時間がかかります", + "Last": "前回", + "Last view": "前回の表示", + "Lateral Chromatic Aberration": "倍率色収差", + "Leave them": "そのまま残す", + "Lens": "レンズ", + "Lens Corrections": "レンズ補正", + "Level": "水平", + "Library": "ライブラリ", + "Library Originals": "ライブラリの元画像フォルダ", + "Light": "明るさ", + "LightCraft Default": "LightCraft標準", + "LightCraft Help": "LightCraftのヘルプ", + "LightCraft Preset": "LightCraftプリセット", + "LightCraft Presets": "LightCraftプリセット", + "LightCraft Website": "LightCraftの公式サイト", + "LightCraft on GitHub": "LightCraftのソースコード", + "LightCraft website": "LightCraftの公式サイト", + "LightCraft — Second Window": "LightCraft — 第2ウィンドウ", + "Lights": "明部", + "Limit file size (KB, 0 = off)": "ファイルサイズ上限(KB、0で無効)", + "Linear Gradient": "線形グラデーション", + "List this folder in Local from now on": "今後このフォルダをローカルに表示", + "Local": "ローカル", + "Local folders": "ローカルフォルダ", + "Local library — no cloud account needed": "ローカルのライブラリ(クラウドアカウント不要)", + "Locate Missing File…": "見つからないファイルを指定…", + "Location": "保存場所", + "Long Edge": "長辺", + "Looking for photos…": "写真を検索中…", + "Lossless": "可逆圧縮", + "Low": "弱", + "Luminance": "輝度", + "Luminance Range": "輝度範囲", + "Magenta": "マゼンタ", + "Magenta Hue": "マゼンタの色相", + "Magenta Luminance": "マゼンタの輝度", + "Magenta Saturation": "マゼンタの彩度", + "Make Candidate the Select": "候補の写真を選択側にする", + "Manual": "手動", + "Masking": "マスク", + "Masking (M)": "マスク(M)", + "Masks": "マスク", + "Match": "一致条件", + "Matte": "マット紙", + "Med": "中", + "Megapixels": "メガピクセル", + "Memory for caches": "キャッシュ用メモリ", + "Menu": "メニュー", + "Merge": "合成", + "Merge Keywords": "キーワードを統合", + "Merge into…": "統合先…", + "Merging…": "合成中…", + "Metadata": "メタデータ", + "Metadata Preset": "メタデータプリセット", + "Metadata Presets": "メタデータプリセット", + "Metadata preset": "メタデータプリセット", + "Midpoint": "中心点", + "Midtones": "中間調", + "Midtones Hue": "中間調の色相", + "Midtones Luminance": "中間調の輝度", + "Midtones Saturation": "中間調の彩度", + "Midtones split": "中間調の境界", + "Minimize": "最小化", + "Missing Photos": "見つからない写真", + "Mode": "モード", + "Modified Date": "変更日時", + "Monochrome": "モノクロ", + "Month": "月", + "More": "その他", + "More preset options": "その他のプリセット設定", + "Move": "移動", + "Move Down in Stack": "スタック内で下に移動", + "Move Folder To…": "フォルダを移動…", + "Move Up in Stack": "スタック内で上に移動", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "ライブラリのOriginalsまたは指定フォルダへ移動し、元の場所から削除", + "Move them": "移動する", + "Move to": "移動先", + "Move to Group": "グループに移動", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "下のフォルダへ移動します。コピーを検証してから元のファイルとXMPを削除します。重複や失敗したファイルは元の場所に残します", + "My Photos": "マイフォト", + "Name": "名前", + "Named": "名前付き", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "ラベル名はメニュー・フィルターバー・情報パネルに表示され、XMPに保存されます。空欄の場合は色名を使います", + "Navigator": "ナビゲーター", + "Needs Work": "要修整", + "Neutral": "ニュートラル", + "Never": "しない", + "New Album…": "新しいアルバム…", + "New Folder…": "新しいフォルダ…", + "New Group…": "新しいグループ…", + "New Smart Album": "新しいスマートアルバム", + "New Smart Album from Filter…": "現在のフィルターからスマートアルバムを作成…", + "New Smart Album…": "新しいスマートアルバム…", + "New album": "新しいアルバム", + "New album…": "新しいアルバム…", + "New name": "新しい名前", + "No": "いいえ", + "No Camera or Card Found": "カメラやメモリーカードが見つかりません", + "No Date Groups": "日付でまとめない", + "No Stars": "星なし", + "No Tool": "ツールなし", + "No automatic versions.": "自動バージョンはありません", + "No camera": "カメラ情報なし", + "No date": "日付なし", + "No dust spots found": "ほこりは見つかりませんでした", + "No edits yet.": "まだ編集していません", + "No exposure information": "露出情報がありません", + "No filter presets yet": "フィルタープリセットはまだありません", + "No lens data embedded in this file (DNG lens opcodes only).": "このファイルにレンズ情報は含まれていません(DNGのレンズ情報のみ対応)", + "No masks yet. Choose a mask type above.": "マスクはありません。上から種類を選択してください", + "No matching photos": "一致する写真がありません", + "No metadata found": "メタデータがありません", + "No new presets": "新しいプリセットはありません", + "No photo selected": "写真が選択されていません", + "No photos": "写真がありません", + "No photos found": "写真が見つかりません", + "No photos in this folder": "このフォルダに写真はありません", + "No photos match the active filters": "現在のフィルターに一致する写真がありません", + "No photos match the filters (View → Clear Filters)": "フィルターに一致する写真がありません(表示 → フィルターを解除)", + "No raw photos yet: cameras appear here once their photos are in the library.": "RAW写真はまだありません。読み込むとカメラがここに表示されます", + "No reference photo": "参照写真がありません", + "No usable GPU found: rendering on the CPU": "利用できるGPUがないため、CPUで描画します", + "No versions yet. Create one to keep this look.": "バージョンはありません。現在の仕上がりを保存できます", + "Noise": "ノイズ", + "Noise Reduction": "ノイズ軽減", + "Non-raw photos": "RAW以外の写真", + "None": "なし", + "Nothing to compare": "比較する写真がありません", + "Nothing to survey": "一覧比較する写真がありません", + "OK": "OK", + "Object": "オブジェクト", + "Off": "オフ", + "Offset X": "水平位置", + "Offset Y": "垂直位置", + "Opacity": "不透明度", + "Opacity (%)": "不透明度(%)", + "Open Library": "ライブラリを開く", + "Open Library…": "ライブラリを開く…", + "Open in": "次で開く", + "Open in Detail": "詳細表示で開く", + "Open the place in OpenStreetMap": "OpenStreetMapでこの場所を開く", + "Optics": "レンズ補正", + "Optional name": "名前(任意)", + "Orange": "オレンジ", + "Orange Hue": "オレンジの色相", + "Orange Luminance": "オレンジの輝度", + "Orange Saturation": "オレンジの彩度", + "Original": "元の比率", + "Original files": "元ファイル", + "Original offline · editing the smart preview": "元画像がオフライン:スマートプレビューを編集中", + "Original offline · no smart preview": "元画像がオフライン:スマートプレビューなし", + "Originals": "元画像", + "Other images (JPEG, PNG, TIFF, HEIC…)": "その他の画像(JPEG・PNG・TIFF・HEICなど)", + "Overlay": "重ね表示", + "Overwrite": "上書き", + "Paint": "描画", + "Paint only areas like the one under the brush": "ブラシ下と似た領域だけに適用", + "Paint over a distraction on the photo to remove it.": "除去する部分をブラシでなぞってください", + "Paint overlay": "マスクの重ね表示", + "Panorama Merge": "パノラマ合成", + "Panorama…": "パノラマ合成…", + "Parametric curve": "パラメトリックカーブ", + "Paste Edit Settings": "編集設定を貼り付け", + "Paste Metadata": "メタデータを貼り付け", + "Paste Selected Settings": "選択した設定を貼り付け", + "Paste Selected Settings…": "選択した設定を貼り付け…", + "Percent": "割合", + "Percentage": "割合", + "Performance": "パフォーマンス", + "Perspective": "遠近法", + "Pet Eye": "ペットの目の補正", + "Photo": "写真", + "Photo Grid": "写真グリッド", + "Photo Grid (G)": "写真グリッド(G)", + "Photo Merge": "写真を合成", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "「写真 → 外部エディターで編集」(⇧⌘E)で16ビットTIFFコピーを作成し、元画像とスタックにまとめて開きます。Macではアプリ名を指定してください", + "Photos": "写真", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "ローカルで参照しただけの写真は、この期間フォルダを開いていなければカタログから外します。ファイルはディスクに残り、再びフォルダを開くと表示されます", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "このフォルダに転送された写真は、転送が完了すると自動で追加されます", + "Pick": "採用", + "Pick a colour on the photo with the eyedropper.": "スポイトで写真の色を選択してください", + "Pick the sharpest photo of each burst": "連写ごとに最も鮮明な写真を採用", + "Picked": "採用", + "Picks": "採用した写真", + "Pixels": "ピクセル", + "Point Color": "ポイントカラー", + "Point Curve": "ポイントカーブ", + "Point Curve Presets": "ポイントカーブのプリセット", + "Point curve": "ポイントカーブ", + "Portrait": "ポートレート", + "Position": "位置", + "Preset": "プリセット", + "Preset name": "プリセット名", + "Presets": "プリセット", + "Presets & Profiles": "プリセットとプロファイル", + "Presets (Shift+P)": "プリセット(⇧P)", + "Presets ▾": "プリセット ▼", + "Preview": "プレビュー", + "Preview only": "プレビューのみ", + "Preview size": "プレビューサイズ", + "Previews": "プレビュー", + "Print": "印刷用", + "Profile": "プロファイル", + "Profile Distortion": "プロファイルのゆがみ補正", + "Profile Vignetting": "プロファイルの周辺光量補正", + "Profile:": "プロファイル:", + "Profiles": "プロファイル", + "Projection": "投影法", + "Public domain": "パブリックドメイン", + "Pupil Size": "瞳孔のサイズ", + "Purple": "パープル", + "Purple Amount": "パープルの適用量", + "Purple Hue": "パープルの色相", + "Purple Hue High": "パープルの色相上限", + "Purple Hue Low": "パープルの色相下限", + "Purple Luminance": "パープルの輝度", + "Purple Saturation": "パープルの彩度", + "Put the locations you removed from Local back (no files change)": "ローカルから除いた場所を再表示(ファイルは変更しません)", + "Quality": "品質", + "Quick Collection": "クイックコレクション", + "Quick Develop": "クイック現像", + "Quit LightCraft": "LightCraftを終了", + "Radial Gradient": "円形グラデーション", + "Radius": "半径", + "Range": "範囲", + "Rating": "評価", + "Ratings & flags": "評価とフラグ", + "Raw default": "RAWの初期設定", + "Raw defaults": "RAWの初期設定", + "Raw files": "RAWファイル", + "Raw photos": "RAW写真", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "編集情報を含むDNGとしてRAW写真を書き出します。サイズ・色・出力の設定は適用されません", + "Read Metadata from File": "ファイルからメタデータを読み込む", + "Reading": "読み込み中", + "Reading folder…": "フォルダを読み込み中…", + "Recently Added": "最近追加した写真", + "Recently Deleted": "最近削除した写真", + "Red": "レッド", + "Red Eye": "赤目補正", + "Red Hue": "レッドの色相", + "Red Luminance": "レッドの輝度", + "Red Primary": "レッド原色", + "Red Saturation": "レッドの彩度", + "Red channel": "レッドチャンネル", + "Red/Cyan Fringe": "赤/シアンのフリンジ", + "Redo": "やり直す", + "Reference": "参照", + "Reference View": "参照表示", + "Reference the files where they are": "現在の場所にあるファイルを参照", + "Refresh Source (/)": "コピー元を更新(/)", + "Reject below focus": "ピントの基準値未満を不採用にする", + "Rejected": "不採用", + "Rejects": "不採用の写真", + "Remove": "除去", + "Remove (H)": "除去(H)", + "Remove CA": "色収差を除去", + "Remove Chromatic Aberration": "色収差を除去", + "Remove from Album": "アルバムから削除", + "Remove from Favorites": "お気に入りから削除", + "Remove from Local": "ローカルから除く", + "Remove from Photo": "写真から削除", + "Remove from Selected Photos": "選択した写真から削除", + "Remove from Stack": "スタックから外す", + "Remove from survey": "一覧比較から除く", + "Remove location info": "位置情報を除去", + "Remove this filter": "このフィルターを解除", + "Remove this group": "このグループを削除", + "Remove this rule": "この条件を削除", + "Rename": "名前を変更", + "Rename Album": "アルバム名を変更", + "Rename Folder…": "フォルダ名を変更…", + "Rename Keyword": "キーワード名を変更", + "Rename Keyword…": "キーワード名を変更…", + "Rename Photos": "写真の名前を変更", + "Rename Photos…": "写真の名前を変更…", + "Rename Version": "バージョン名を変更", + "Rename…": "名前を変更…", + "Rendering": "描画", + "Rendering…": "描画中…", + "Reset": "リセット", + "Reset All Curves": "すべてのカーブをリセット", + "Reset Before to Import State": "編集前を読み込み時の状態に戻す", + "Reset Control": "設定をリセット", + "Reset Crop": "切り抜きをリセット", + "Reset Edits": "編集をリセット", + "Reset Parametric Curve": "パラメトリックカーブをリセット", + "Reset Section": "項目をリセット", + "Reset Tone Curve": "トーンカーブをリセット", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "すべてのポイントカーブとパラメトリックカーブをリセット(チャンネルのダブルクリックで個別にリセット)", + "Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)", + "Resize to": "サイズ変更", + "Resolution (ppi)": "解像度(ppi)", + "Restore": "復元", + "Retouch": "レタッチ", + "Review Import": "読み込み内容を確認", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "写真を右クリックして参照写真に設定し、「表示 → 参照表示」(⇧R)を選択してください", + "Right-click a preset to delete it": "プリセットを右クリックして削除", + "Rights Usage Terms": "利用条件", + "Rotate": "回転", + "Rotate Left": "左に回転", + "Rotate Right": "右に回転", + "Roughness": "粗さ", + "Roundness": "丸み", + "Same as raw default": "RAWの初期設定と同じ", + "Sample a colour on the photo": "写真から色を選択", + "Saturation": "彩度", + "Saturation Range": "彩度の範囲", + "Save": "保存", + "Save Current Filter as Preset…": "現在のフィルターをプリセットとして保存…", + "Save Current Keywords as Set…": "現在のキーワードをセットとして保存…", + "Save Metadata to File": "メタデータをファイルに保存", + "Save Point Curve…": "ポイントカーブを保存…", + "Save as Preset": "プリセットとして保存", + "Save as Smart Album…": "スマートアルバムとして保存…", + "Save as set": "セットとして保存", + "Save preset": "プリセットを保存", + "Scale": "拡大率", + "Scanning": "検索中", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "評価結果は写真に保存されます。ピント評価や類似写真内のベストショットで絞り込み・スマートアルバム作成ができます", + "Screen": "画面用", + "Search Photos": "写真を検索", + "Second Window": "第2ウィンドウ", + "Select": "選択", + "Select All": "すべて選択", + "Select Luminance Range": "輝度範囲を選択", + "Select a photo to edit": "編集する写真を選択してください", + "Select by": "条件で選択", + "Select by Flag, Rating or Label": "フラグ・評価・ラベルで選択", + "Select photos, then choose View → Survey (N)": "写真を選び、「表示 → 一覧比較」(N)を選択してください", + "Select two photos, then choose View → Compare (Shift+C)": "2枚を選び、「表示 → 比較」(⇧C)を選択してください", + "Selected": "選択中", + "Send Feedback…": "フィードバックを送る…", + "Services": "サービス", + "Set Color Label": "カラーラベルを設定", + "Set Develop Values": "現像設定を変更", + "Set Flag": "フラグを設定", + "Set Point Curve": "ポイントカーブを変更", + "Set Profile": "プロファイルを設定", + "Set Rating": "評価を設定", + "Set as Album Cover": "アルバムのカバーに設定", + "Set as Before": "編集前に設定", + "Set as Reference Photo": "参照写真に設定", + "Set as Target Album": "追加先アルバムに設定", + "Set as Target Album (B adds to it)": "追加先アルバムに設定(Bで追加)", + "Set as Top of Stack": "スタックの先頭に設定", + "Set the mix from the photo's colours": "写真の色からミックスを自動設定", + "Settings": "設定", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新しく読み込むRAW写真の初期設定です。既存の写真には影響しません", + "Settings to include": "含める設定", + "Settings…": "設定…", + "Shade": "日陰", + "Shadow": "シャドウ", + "Shadows": "シャドウ", + "Shadows Hue": "シャドウの色相", + "Shadows Luminance": "シャドウの輝度", + "Shadows Saturation": "シャドウの彩度", + "Shadows Tint": "暗部の色かぶり補正", + "Shadows split": "シャドウの境界", + "Sharpen": "シャープ処理", + "Sharpening": "シャープ", + "Sharpness": "シャープネス", + "Short Edge": "短辺", + "Show All": "すべて表示", + "Show Clipping": "白飛び・黒つぶれを表示", + "Show Deghost Overlay": "ゴースト除去範囲を表示", + "Show Hidden Local Locations": "非表示にしたローカルの場所を表示", + "Show Luminance Map": "輝度マップを表示", + "Show Mask": "マスクを表示", + "Show Mask Overlay": "マスクの重ね表示", + "Show Mask Pins": "マスクのピンを表示", + "Show Original": "元の写真を表示", + "Show Photo Counts": "写真の枚数を表示", + "Show Photos with Keyword": "このキーワードの写真を表示", + "Show Pins": "ピンを表示", + "Show Source": "コピー元を表示", + "Show Thumbnails": "サムネイルを表示", + "Show destination gamut warning": "出力先の色域警告を表示", + "Show display gamut warning": "ディスプレイの色域警告を表示", + "Show file names": "ファイル名を表示", + "Show in Finder": "Finderで表示", + "Show on Map": "地図で表示", + "Show overlay (O)": "重ね表示(O)", + "Show ratings, flags and edit badges": "評価・フラグ・編集済みマークを表示", + "Show the Navigator while zoomed in": "拡大時にナビゲーターを表示", + "Show the template tags; click one to insert it": "テンプレートのタグを表示(クリックで挿入)", + "Show/hide My Photos panel": "マイフォトパネルを表示/非表示", + "Shutter speed": "シャッター速度", + "Sidecar names": "サイドカーファイル名", + "Size": "サイズ", + "Size (% of short edge)": "サイズ(短辺に対する%)", + "Size limit": "サイズの上限", + "Skip": "スキップ", + "Sky": "空", + "Smart Album": "スマートアルバム", + "Smart previews": "スマートプレビュー", + "Smoothness": "滑らかさ", + "Soft Proofing": "ソフト校正", + "Sort": "並べ替え", + "Sort by": "並べ替え", + "Source": "読み込み元", + "Source code on GitHub": "GitHubのソースコード", + "Spherical": "球面", + "Spiral": "螺旋", + "Square Grid": "正方形グリッド", + "Square Grid (G toggles)": "正方形グリッド(Gで切り替え)", + "Square Grid: show file names and formats": "正方形グリッド:ファイル名と形式を表示", + "Stack": "スタック", + "Stack photos taken within this time of each other:": "この時間内に撮影した写真をスタックにまとめる:", + "Stacks": "スタック", + "Standard": "標準", + "Start at": "開始番号", + "Start number": "開始番号", + "Startup": "起動時", + "State / Province": "都道府県・州", + "Stop": "停止", + "Stop Building Previews": "プレビュー作成を停止", + "Stop Using as Target Album": "追加先アルバムを解除", + "Stopping…": "停止中…", + "Straighten": "角度補正", + "Straighten Tool": "角度補正ツール", + "Style": "スタイル", + "Subfolder": "サブフォルダ", + "Subfolder name": "サブフォルダ名", + "Subject": "被写体", + "Subtract": "減算", + "Suggestions": "候補", + "Survey": "一覧比較", + "Survey (N)": "一覧比較(N)", + "Swap": "入れ替え", + "Swap Aspect (X)": "縦横比を入れ替え(X)", + "Swap Before and After Settings": "編集前と編集後の設定を入れ替える", + "Sync Settings": "設定を同期", + "System Info": "システム情報", + "System Info…": "システム情報…", + "System default": "システム標準", + "Tags": "タグ", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "テンプレート横の「タグ」を参照してください。ファイルとXMPの名前を変更し、同名の場合は-1、-2…を付けます", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "写真の上を上下にドラッグして、その部分の色を調整", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "写真の上を上下にドラッグして、その部分のカーブを調整", + "Temp": "色温度", + "Temperature": "色温度", + "Template": "テンプレート", + "Text": "文字", + "Texture": "テクスチャ", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "元のファイルを変更せず、編集内容を保存したXMPサイドカーファイルを添えて書き出します", + "The photo has these settings": "この写真に適用されている設定", + "They can be restored from Recently Deleted until it is emptied.": "「最近削除した写真」を空にするまでは復元できます", + "Thirds": "三分割", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "このフォルダにアクセスできません。ドライブを接続するか別のフォルダを選ぶまで、スマートプレビューは作成・使用されません", + "This photo can't be opened": "この写真を開けません", + "Threshold": "しきい値", + "Thumbnail cache": "サムネイルキャッシュ", + "Tint": "色かぶり補正", + "Title": "タイトル", + "Tone Curve": "トーンカーブ", + "Tools": "ツール", + "Top Level": "最上位", + "Transfer": "転送", + "Treatment": "処理", + "Triangle": "三角形", + "Tungsten": "白熱灯", + "Turn Off": "無効にする", + "Turn on Include subfolders, or pick another folder under Local": "サブフォルダを含める設定を有効にするか、ローカルから別のフォルダを選択してください", + "Uncheck All": "すべて選択解除", + "Undo": "取り消す", + "Unedited": "未編集", + "Unflag": "フラグを解除", + "Unflagged": "フラグなし", + "Ungroup Stack": "スタックを解除", + "Unrated": "未評価", + "Unreadable": "読み込み不可", + "Update": "更新", + "Update Rules from Current Filter": "現在のフィルターから条件を更新", + "Update Version": "バージョンを更新", + "Update with Current Settings": "現在の設定で更新", + "Upright": "遠近補正", + "Use Library Folder": "ライブラリのフォルダを使用", + "Use camera-specific defaults": "カメラごとの初期設定を使用", + "Use the GPU for rendering": "描画にGPUを使用", + "User Presets": "ユーザープリセット", + "Value": "値", + "Variance": "ばらつき", + "Version": "バージョン", + "Version name": "バージョン名", + "Versions": "バージョン", + "Versions (Shift+V)": "バージョン(⇧V)", + "Vertical": "垂直", + "Vibrance": "自然な彩度", + "Videos": "動画", + "View": "表示", + "Vignette": "周辺光量補正", + "Vignetting": "周辺光量補正", + "Visualize Spots": "スポットを表示", + "Visualize Spots (A)": "スポットを表示(A)", + "Visualize range": "範囲を表示", + "Vivid": "鮮やか", + "Watched folder": "監視フォルダ", + "Watermark": "透かし", + "What's New": "新着情報", + "When rated or hovered": "評価時またはカーソルを合わせたとき", + "White Balance": "ホワイトバランス", + "White Balance Selector": "ホワイトバランス選択", + "White Balance Selector (W)": "ホワイトバランス選択(W)", + "Whites": "白レベル", + "Width": "幅", + "Width (% of photo)": "幅(写真に対する%)", + "Width (px)": "幅(px)", + "Width × Height": "幅 × 高さ", + "Window": "ウィンドウ", + "XMP sidecars": "XMPサイドカーファイル", + "Year": "年", + "Yellow": "イエロー", + "Yellow Hue": "イエローの色相", + "Yellow Luminance": "イエローの輝度", + "Yellow Saturation": "イエローの彩度", + "Yes": "はい", + "Zoom": "拡大/縮小", + "Zoom 100%": "100%表示", + "Zoom In": "拡大", + "Zoom Out": "縮小", + "Zoom to Fit": "全体を表示", + "and": "と", + "comma, separated": "カンマ区切り", + "memory only": "メモリ上のみ", + "none": "なし", + "not available here": "ここでは利用できません", + "of the following rules:": "以下の条件に一致:", + "Effect": "効果", + "Image": "画像", + "Layer": "レイヤー", + "Type": "書式", + "Save As…": "別名で保存…", + "Exit": "終了", + "New…": "新規…", + "New": "新規", + "Orientation": "組み方向", + "Layers": "レイヤー", + "Properties": "プロパティ", + "Brush Settings": "ブラシ設定", + "Options": "オプション", + "Fit on Screen": "画面に合わせる", + "Cut": "切り取り", + "Paste": "貼り付け", + "Deselect": "選択を解除", + "Export As…": "形式を指定して書き出し…", + "Search…": "検索…", + "Theme": "テーマ", + "Pages": "ページ", + "Preferences": "環境設定", + "Preferences…": "環境設定…", + "Interface language": "表示言語", + "Open…": "開く…", + "New blank PDF": "空白の PDF を作成", + "Create PDF from file…": "ファイルから PDF を作成…", + "Create PDF from images…": "画像から PDF を作成…", + "Create PDF from clipboard": "クリップボードから PDF を作成", + "Combine files…": "ファイルを結合…", + "Save as…": "別名で保存…", + "Close file": "ファイルを閉じる", + "Close all": "すべて閉じる", + "Revert": "保存済みの状態に戻す", + "Print…": "印刷…", + "Document properties…": "文書のプロパティ…", + "Find…": "検索…", + "Advanced search…": "高度な検索…", + "Copy pages": "ページをコピー", + "Cut pages": "ページを切り取り", + "Paste pages": "ページを貼り付け", + "Fit visible": "表示範囲に合わせる", + "Marquee zoom": "範囲指定ズーム", + "Take a snapshot": "スナップショットを作成", + "Full screen mode": "全画面表示", + "Read mode": "閲覧モード", + "Switch light / dark theme": "明るい/暗いテーマを切り替え", + "Comments panel": "コメントパネル", + "Form fields panel": "フォームフィールドパネル", + "Clear form": "フォームをクリア", + "Find tools and commands…": "ツールとコマンドを検索…", + "Actual size": "実際のサイズ", + "Zoom to page level": "ページ全体を表示", + "Fit to width": "幅に合わせる", + "Display theme": "表示テーマ", + "Side panels": "サイドパネル", + "Text direction": "文字の方向", + "Horizontal text": "横書き", + "Vertical text": "縦書き", + "Your library couldn't be opened": "ライブラリを開けませんでした", + "LightCraft couldn't find where to keep your library.": "ライブラリの保存場所が見つかりませんでした。", + "LightCraft couldn't open the library at": "次の場所にあるライブラリを開けませんでした:", + "Try Again": "再試行", + "Choose Another Library…": "別のライブラリを選択…", + "Continue Without Saving": "保存せずに続行", + "Quit": "終了", + "Temporary session — your library isn't open, and nothing you do here is saved.": "一時セッション:ライブラリが開かれていないため、ここでの操作は保存されません。", + "Back Up Library…": "ライブラリをバックアップ…", + "Restore Library from Backup…": "バックアップからライブラリを復元…" +} diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 6726c5f5..8f21013d 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -75,12 +75,13 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let ok = files.iter().filter(|f| f.get("path").is_some()).count(); let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect(); let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count(); - let mut m = format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }); + let mut m = + crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total); if skipped > 0 { - m += &format!(" · {skipped} skipped (file exists)"); + m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); } if let Some(first) = failed.first() { - m += &format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); + m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); } if cancelled { m += " · cancelled"; @@ -96,14 +97,17 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); let (total, cancel) = (task.total, task.cancel.clone()); let t = Tokens::get(ctx); - egui::Window::new("Exporting") + egui::Window::new(crate::i18n::tr("Exporting")) .title_bar(false) .resizable(false) .anchor(Align2::LEFT_BOTTOM, [16.0, -56.0]) .fixed_size([300.0, 64.0]) .show(ctx, |ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("Exporting {} of {total}", (done + 1).min(total))).color(t.text)); + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Exporting {} of {total}", (done + 1).min(total), total = total)) + .color(t.text), + ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let stopping = cancel.load(Ordering::Relaxed); if crate::widgets::text_button(ui, "exportCancel", if stopping { "Stopping…" } else { "Cancel" }, false).clicked() { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 36e004c8..92355c30 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1,24 +1,27 @@ -//! Interface translations. Command ids, document text and file names remain stable. -//! Untranslated labels fall back to English so coverage can grow incrementally. +//! Presentation-only localization. Command ids, user names and catalog data remain unchanged. +use std::{cell::Cell, collections::BTreeMap, sync::OnceLock}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] +use serde::{Deserialize, Serialize}; + +include!(concat!(env!("OUT_DIR"), "/ja-formats.rs")); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Language { #[default] + #[serde(rename = "en")] En, + #[serde(rename = "ja")] Ja, } impl Language { pub const ALL: [Self; 2] = [Self::En, Self::Ja]; - pub fn name(self) -> &'static str { match self { Self::En => "English", Self::Ja => "日本語", } } - pub fn parse(code: &str) -> Option { match code { "en" => Some(Self::En), @@ -26,129 +29,134 @@ impl Language { _ => None, } } - - pub fn tr(self, text: &str) -> &str { - if self == Self::Ja - && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) - { - return japanese; - } - text + pub fn tr(self, source: &str) -> &str { + if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source } } } -const JAPANESE: &[(&str, &str)] = &[ - ("Photo", "写真"), - ("Import Photos…", "写真を読み込み…"), - ("Detail", "詳細"), - ("Photo Grid", "写真グリッド"), - ("Square Grid", "正方形グリッド"), - ("Object", "オブジェクト"), - ("Effect", "効果"), - ("Settings…", "環境設定…"), - ("Image", "画像"), - ("Layer", "レイヤー"), - ("Type", "書式"), - ("Select", "選択"), - ("Filter", "フィルター"), - ("Window", "ウィンドウ"), - ("Language", "表示言語"), - ("Save As…", "別名で保存…"), - ("Exit", "終了"), - ("New…", "新規…"), - ("New", "新規"), - ("Horizontal", "横書き"), - ("Vertical", "縦書き"), - ("Orientation", "組み方向"), - ("Layers", "レイヤー"), - ("History", "履歴"), - ("Properties", "プロパティ"), - ("Color", "カラー"), - ("Brush Settings", "ブラシ設定"), - ("Tools", "ツール"), - ("Options", "オプション"), - ("Zoom In", "ズームイン"), - ("Zoom Out", "ズームアウト"), - ("Fit on Screen", "画面に合わせる"), - ("Copy", "コピー"), - ("Cut", "切り取り"), - ("Paste", "貼り付け"), - ("Select All", "すべて選択"), - ("Deselect", "選択を解除"), - ("Export", "書き出し"), - ("Export As…", "形式を指定して書き出し…"), - ("Search…", "検索…"), - ("Theme", "テーマ"), - ("Menu", "メニュー"), - ("File", "ファイル"), - ("Edit", "編集"), - ("Pages", "ページ"), - ("View", "表示"), - ("Help", "ヘルプ"), - ("Preferences", "環境設定"), - ("Preferences…", "環境設定…"), - ("Interface language", "表示言語"), - ("Open…", "開く…"), - ("New blank PDF", "空白の PDF を作成"), - ("Create PDF from file…", "ファイルから PDF を作成…"), - ("Create PDF from images…", "画像から PDF を作成…"), - ("Create PDF from clipboard", "クリップボードから PDF を作成"), - ("Combine files…", "ファイルを結合…"), - ("Save", "保存"), - ("Save as…", "別名で保存…"), - ("Close file", "ファイルを閉じる"), - ("Close all", "すべて閉じる"), - ("Revert", "保存済みの状態に戻す"), - ("Print…", "印刷…"), - ("Document properties…", "文書のプロパティ…"), - ("Undo", "取り消し"), - ("Redo", "やり直し"), - ("Find…", "検索…"), - ("Advanced search…", "高度な検索…"), - ("Copy pages", "ページをコピー"), - ("Cut pages", "ページを切り取り"), - ("Paste pages", "ページを貼り付け"), - ("Fit visible", "表示範囲に合わせる"), - ("Marquee zoom", "範囲指定ズーム"), - ("Take a snapshot", "スナップショットを作成"), - ("Full screen mode", "全画面表示"), - ("Read mode", "閲覧モード"), - ("Switch light / dark theme", "明るい/暗いテーマを切り替え"), - ("Comments panel", "コメントパネル"), - ("Form fields panel", "フォームフィールドパネル"), - ("Clear form", "フォームをクリア"), - ("Find tools and commands…", "ツールとコマンドを検索…"), - ("Zoom", "ズーム"), - ("Actual size", "実際のサイズ"), - ("Zoom to page level", "ページ全体を表示"), - ("Fit to width", "幅に合わせる"), - ("Display theme", "表示テーマ"), - ("Side panels", "サイドパネル"), - ("OK", "OK"), -]; +thread_local! { + static LANGUAGE: Cell = const { Cell::new(Language::En) }; +} + +pub fn default_language() -> Language { + if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En } +} + +pub fn set_language(language: Language) { + LANGUAGE.with(|value| value.set(language)); +} + +pub fn is_japanese() -> bool { + LANGUAGE.with(|value| value.get() == Language::Ja) +} + +fn japanese() -> &'static BTreeMap { + static MESSAGES: OnceLock> = OnceLock::new(); + MESSAGES.get_or_init(|| { + serde_json::from_str(include_str!("../locales/ja.json")).unwrap_or_else(|error| { + log::error!("Invalid Japanese message catalog: {error}"); + BTreeMap::new() + }) + }) +} + +/// 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 } +} #[cfg(test)] mod tests { use super::*; #[test] - fn language_setting_persists() { - let state = crate::state::UiState { language: Language::Ja, ..Default::default() }; - let saved = serde_json::to_string(&state).unwrap(); - let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Language::Ja); - assert_eq!(crate::state::UiState::default().language, Language::En); + fn catalog_is_valid_and_contains_core_workflows() { + let messages: BTreeMap = serde_json::from_str(include_str!("../locales/ja.json")).unwrap(); + for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { + assert!(messages.get(key).is_some_and(|value| !value.is_empty() && value != key), "{key}"); + } } #[test] - fn translations_are_unique_and_preserve_unknown_text() { - for (i, (en, ja)) in JAPANESE.iter().enumerate() { - assert!(!ja.is_empty()); - assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); - assert_eq!(Language::En.tr(en), *en); + fn language_switches_and_unknown_text_survives() { + set_language(Language::Ja); + assert_eq!(tr("Exposure"), "露出"); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); + set_language(Language::En); + assert_eq!(tr("Exposure"), "Exposure"); + } + + #[test] + fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() { + set_language(Language::Ja); + assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); + set_language(Language::En); + assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos"); + } + + #[test] + fn preferences_round_trip_and_old_settings_remain_readable() { + let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); + assert_eq!(old.language, Language::En); + let settings = crate::state::UiState { language: Language::Ja, ..old }; + let saved = serde_json::to_string(&settings).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); + } + + #[test] + fn japanese_is_painted_and_both_font_weights_cover_the_catalog() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.language = Language::Ja; + app.ui.left_panel = true; + let mut text = String::new(); + fn 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)), + _ => {} + } } - assert_eq!(Language::Ja.tr("File"), "ファイル"); - assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); - assert_eq!(Language::parse("xx"), None); + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + // This assertion inspects shapes without a renderer; discard texture uploads explicitly. + out.textures_delta.clear(); + text.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut text); + } + } + assert!(text.contains("マイフォト"), "{text}"); + assert!(text.contains("すべての写真"), "{text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for message in japanese().values() { + for ch in message.chars().filter(|ch| !ch.is_whitespace()) { + assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}"); + } + } + } + }); + // Locale affects presentation only: command ids remain the same. + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let japanese_ids = ids(&app); + set_language(Language::En); + assert_eq!(japanese_ids, ids(&app)); } } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 2058eb97..7e520bb3 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -412,19 +412,21 @@ pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) { let text = if total == 0 { if task.browse { "Reading folder…" } else { "Looking for photos…" }.to_string() } else { - format!("Reading photos… {done} of {total}") + crate::i18n::tr_format!("Reading photos… {done} of {total}", done = done, total = total) }; let mut cancel = false; - egui::Window::new("Scanning").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 80.0]).show( - ctx, - |ui| { + egui::Window::new(crate::i18n::tr("Scanning")) + .title_bar(false) + .resizable(false) + .anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]) + .fixed_size([340.0, 80.0]) + .show(ctx, |ui| { ui.label(egui::RichText::new(text).color(t.text)); ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(320.0)); - let r = ui.button("Cancel"); + let r = ui.button(crate::i18n::tr("Cancel")); register(ui.ctx(), "button:scanCancel", r.rect); cancel = r.clicked(); - }, - ); + }); if cancel { // the worker stops at its next file; don't wait for it (a NAS read can take a while) task.progress.cancel.store(true, Ordering::Relaxed); @@ -581,7 +583,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc /// The import is done (or cancelled): one undo step, select the first photo, say what happened. fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { let steps = app.session.undo.len().saturating_sub(task.undo0); - let label = format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }); + let label = crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }); app.session.merge_undo(steps, &label); if task.auto { if task.imported > 0 { @@ -591,7 +593,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } if task.browse { if task.failed > 0 { - app.toast(ctx, format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); + app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); } return; } @@ -600,21 +602,21 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } let plural = |n: usize| if n == 1 { "" } else { "s" }; let mut msg = if task.cancelled { - format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) + crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) } else { - format!("Imported {} photo{}", task.imported, plural(task.imported)) + crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) }; if task.params["mode"] == "move" { - msg.push_str(&format!(" · {} moved", task.moved)); + msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved)); if task.kept > 0 { msg.push_str(&format!(" · {} original{} left at the source", task.kept, plural(task.kept))); } } if task.duplicates > 0 { - msg.push_str(&format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); } if task.failed > 0 { - msg.push_str(&format!(" · {} not readable", task.failed)); + msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); } app.toast(ctx, msg); } @@ -626,21 +628,25 @@ pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let frac = task.done as f32 / task.total.max(1) as f32; let text = if task.cancelled { - "Stopping…".to_string() + crate::i18n::tr("Stopping…").to_string() + } else if task.browse { + crate::i18n::tr_format!("Reading photos… {} of {}", task.done, task.total) } else { - format!("{} photos… {} of {}", if task.browse { "Reading" } else { "Adding" }, task.done, task.total) + crate::i18n::tr_format!("Adding photos… {} of {}", task.done, task.total) }; let mut cancel = false; - egui::Window::new("Importing").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 80.0]).show( - ctx, - |ui| { + egui::Window::new(crate::i18n::tr("Importing")) + .title_bar(false) + .resizable(false) + .anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]) + .fixed_size([340.0, 80.0]) + .show(ctx, |ui| { ui.label(egui::RichText::new(text).color(t.text)); ui.add(egui::ProgressBar::new(frac).desired_width(320.0)); - let r = ui.add_enabled(!task.cancelled, egui::Button::new("Cancel")); + let r = ui.add_enabled(!task.cancelled, egui::Button::new(crate::i18n::tr("Cancel"))); register(ui.ctx(), "button:importCancel", r.rect); cancel = r.clicked(); - }, - ); + }); if cancel && let Some(task) = app.import.as_mut() { task.cancelled = true; if let Some(run) = &task.run { @@ -656,15 +662,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count(); let sel = d.selected_paths().len(); ui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("{n} found · {sel} selected")).color(t.text)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); if dups > 0 { - ui.label(egui::RichText::new(format!("· {dups} already in the library")).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::text_button(ui, "importNone", "Uncheck All", false).clicked() { + if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { d.checked.iter_mut().for_each(|c| *c = false); } - if crate::widgets::text_button(ui, "importAll", "Check All", false).clicked() { + if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { for i in 0..n { d.checked[i] = d.importable(i); } @@ -702,25 +708,32 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { register(ui.ctx(), "label:importSource", r.rect); }); ui.label( - egui::RichText::new("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).") + egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")) .color(t.text_dim) .small(), ); } field(ui, "Transfer", |ui| { ui.spacing_mut().item_spacing.x = 4.0; - if crate::widgets::text_button(ui, "importAdd", "Add in place", !d.copy).on_hover_text("Reference the files where they are").clicked() { + if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy) + .on_hover_text(crate::i18n::tr("Reference the files where they are")) + .clicked() + { d.copy = false; d.move_files = false; } let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); - let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", "Copy", d.copy && !d.move_files)).inner; - if r.on_hover_text("Copy the files (into the library's Originals/, or a folder you choose)").clicked() { + let r = + ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner; + if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() { d.copy = true; d.move_files = false; } - let r = ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", "Move", d.copy && d.move_files)).inner; - if r.on_hover_text("Move the files (into the library's Originals/, or a folder you choose), removing them from the source").clicked() { + let r = + ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner; + if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source")) + .clicked() + { d.copy = true; d.move_files = true; } @@ -745,12 +758,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() }; ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "importDest", "Choose…", false).clicked() + && crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked() && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) { d.destination = f; } - if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", "Library", false).clicked() { + if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() { d.destination.clear(); } }); @@ -797,14 +810,16 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { None => unknown_tags_warning(ui, &d.folder_template), } ui.label( - egui::RichText::new("Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").") - .color(t.text_dim) - .small(), + egui::RichText::new(crate::i18n::tr( + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").", + )) + .color(t.text_dim) + .small(), ); } if !d.move_files { field(ui, "Raw files", |ui| { - let r = ui.checkbox(&mut d.dng, "Copy as DNG"); + let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG")); register(ui.ctx(), "check:importDng", r.rect); }); } @@ -822,10 +837,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { unknown_tags_warning(ui, &d.rename); if let Some(example) = example_destination(app, d) { let t = Tokens::get(ui.ctx()); - let r = ui.label(egui::RichText::new(format!("Example: {example}")).color(t.text_dim)); + let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim)); register(ui.ctx(), "label:importExample", r.rect); ui.label( - egui::RichText::new("Folders and {date} use the capture time; a photo without one uses today's date.").color(t.text_dim).small(), + egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date.")) + .color(t.text_dim) + .small(), ); } } @@ -842,11 +859,11 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { None => "None".into(), }; egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| { - if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), "None").clicked() { + if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() { d.album = None; d.new_album.clear(); } - if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), "New album…").clicked() { + if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() { d.album = None; if d.new_album.is_empty() { d.new_album = "Imported Photos".into(); @@ -867,7 +884,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { field(ui, "Preset", |ui| { let cur = app.session.presets.iter().find(|p| p.id == d.preset).map(|p| p.name.clone()).unwrap_or_else(|| "None".into()); egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { - if ui.selectable_label(d.preset.is_empty(), "None").clicked() { + if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { d.preset.clear(); } for p in &app.session.presets { @@ -881,7 +898,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { field(ui, "Metadata", |ui| { let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() }; egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| { - if ui.selectable_label(d.metadata_preset.is_empty(), "None").clicked() { + if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { d.metadata_preset.clear(); } for m in &app.session.metadata_presets { @@ -893,7 +910,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { }); } field(ui, "Keywords", |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text("comma, separated").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY)); register(ui.ctx(), "field:importKeywords", r.rect); }); } @@ -966,7 +983,8 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool { let id = egui::Id::new((key, "tags-open")); let mut open = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or(false); - let r = crate::widgets::text_button(ui, &format!("{key}Tags"), "Tags", open).on_hover_text("Show the template tags; click one to insert it"); + let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open) + .on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it")); if r.clicked() { open = !open; ui.ctx().data_mut(|d| d.insert_temp(id, open)); @@ -1010,7 +1028,9 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: let t = Tokens::get(ui.ctx()); egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| { ui.label( - egui::RichText::new("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.").color(t.text_dim).small(), + egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.")) + .color(t.text_dim) + .small(), ); egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| { egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| { @@ -1088,9 +1108,9 @@ pub fn source_summary(sources: &[String]) -> String { many => { let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count(); match folders { - 0 => format!("{} files", many.len()), - f if f == many.len() => format!("{f} folders (and their subfolders)"), - f => format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }), + 0 => crate::i18n::tr_format!("{} files", many.len()), + f if f == many.len() => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f), + f => crate::i18n::tr_format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }, f = f), } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b22e156..4f489f88 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -480,6 +480,7 @@ impl LightcraftApp { /// Per-frame logic before layout (control channel, renders, shortcuts, drops). pub fn logic(&mut self, ctx: &egui::Context) { + i18n::set_language(self.ui.language); let t0 = now_ms(); panels::library_problem::logic(self); self.logic_inner(ctx); @@ -657,6 +658,7 @@ impl LightcraftApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { + i18n::set_language(self.ui.language); let ctx = ui.ctx().clone(); if !self.fonts_ready { ctx.request_repaint(); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index a48069d3..f122041e 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -43,6 +43,15 @@ impl MenuNode { } } +/// User-defined names in parameterized menus are data, not message keys. +pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a str { + if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) { + label + } else { + crate::i18n::tr(label) + } +} + /// Top-level menus in order (the macOS app menu is added by the host). pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"]; @@ -307,11 +316,15 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { 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| format!("Undo {}", e.label)).unwrap_or_else(|| "Undo".into()), - "edit.redo" => app.session.redo.last().map(|e| format!("Redo {}", e.label)).unwrap_or_else(|| "Redo".into()), - "photo.delete" if n > 1 => format!("Delete {n} Photos"), - "photo.virtualCopy" if n > 1 => format!("Create {n} Virtual Copies"), - "dialog.rename" if n > 1 => format!("Rename {n} Photos…"), + "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()) + } + "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), _ => label.to_string(), } } @@ -476,14 +489,21 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { .map(|(f, label)| item("library.selectBy", json!({"flag": f}), label, None, true, None)) .collect(); v.push(MenuNode::Separator); - v.extend( - (1..=5u8).map(|r| item("library.selectBy", json!({"rating": r}), format!("{} and higher", "★".repeat(r as usize)), None, true, None)), - ); + v.extend((1..=5u8).map(|r| { + item("library.selectBy", json!({"rating": r}), crate::i18n::tr_format!("{} and higher", "★".repeat(r as usize)), None, true, None) + })); v.push(item("library.selectBy", json!({"rating": 0, "ratingOp": "eq"}), "Unrated", None, true, None)); v.push(MenuNode::Separator); v.extend(lightcraft_catalog::ColorLabel::ALL.iter().map(|l| { let name = format!("{l:?}"); - item("library.selectBy", json!({"label": name.to_lowercase()}), format!("{name} Label"), None, true, None) + item( + "library.selectBy", + json!({"label": name.to_lowercase()}), + crate::i18n::tr_format!("{name} Label", name = name), + None, + true, + None, + ) })); v } @@ -608,22 +628,6 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { } bar.push((title.to_string(), tidy(items))); } - fn translate(nodes: &mut [MenuNode], language: crate::i18n::Language) { - for node in nodes { - match node { - MenuNode::Submenu { label, children } => { - *label = language.tr(label).to_string(); - translate(children, language); - } - MenuNode::Item { label, .. } => *label = language.tr(label).to_string(), - MenuNode::Separator => {} - } - } - } - for (title, nodes) in &mut bar { - *title = app.ui.language.tr(title).to_string(); - translate(nodes, app.ui.language); - } bar } @@ -677,9 +681,9 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String { const TITLE_GAP: f32 = 24.0; /// Width of the in-window menu bar's titles. -pub fn bar_width(ui: &egui::Ui, language: crate::i18n::Language) -> f32 { +pub fn bar_width(ui: &egui::Ui) -> f32 { let t = crate::theme::Tokens::get(ui.ctx()); - MENUS.iter().map(|m| ui.painter().layout_no_wrap(language.tr(m).to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::() + MENUS.iter().map(|m| ui.painter().layout_no_wrap(crate::i18n::tr(m).to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::() } /// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single @@ -688,8 +692,10 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 let t = crate::theme::Tokens::get(ui.ctx()); let bar = menu_bar(app); let font = t.font(13.0); - let widths: Vec = - bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + TITLE_GAP).collect(); + let widths: Vec = bar + .iter() + .map(|(title, _)| ui.painter().layout_no_wrap(crate::i18n::tr(title).to_string(), font.clone(), t.text).size().x + TITLE_GAP) + .collect(); let total: f32 = widths.iter().sum(); let mut clicked: Option<(String, Value)> = None; let start = ui.cursor().left(); @@ -697,18 +703,18 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 if total <= max_width { let saved = ui.spacing().item_spacing.x; ui.spacing_mut().item_spacing.x = TITLE_GAP; - for (index, (title, items)) in bar.iter().enumerate() { - let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false)); - crate::widgets::register(ui.ctx(), format!("menu:{}", MENUS.get(index).copied().unwrap_or(title.as_str())), r.rect); + for (title, items) in &bar { + let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false)); + crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect); egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked)); } ui.spacing_mut().item_spacing.x = saved; } else { - let r = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Menu")).font(font.clone()).color(t.text_label)).frame(false)); + let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false)); crate::widgets::register(ui.ctx(), "menu:all", r.rect); egui::Popup::menu(&r).show(|ui| { for (title, items) in &bar { - ui.menu_button(title, |ui| nodes_ui(ui, items, mac, &mut clicked)); + ui.menu_button(crate::i18n::tr(title), |ui| nodes_ui(ui, items, mac, &mut clicked)); } }); } @@ -726,11 +732,11 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti ui.separator(); } MenuNode::Submenu { label, children } => { - ui.menu_button(format!(" {label}"), |ui| nodes_ui(ui, children, mac, clicked)); + ui.menu_button(format!(" {}", crate::i18n::tr(label)), |ui| nodes_ui(ui, children, mac, clicked)); } MenuNode::Item { id, params, label, shortcut, enabled, checked } => { // a gutter for check marks, like native menus - let mut b = egui::Button::new(format!(" {label}")); + let mut b = egui::Button::new(format!(" {}", display_item_label(id, params, label))); if let Some(sc) = shortcut { b = b.shortcut_text(shortcut_text(sc, mac)); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c3b6ae21..5d838b75 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -151,10 +151,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, format!("{name} Off")); + app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = name)); } else { app.ui.right = p; - app.toast(ctx, format!("{name} On")); + app.toast(ctx, crate::i18n::tr_format!("{name} On", name = name)); if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { app.ui.view = ViewMode::Detail; } @@ -874,7 +874,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option format!("none (CPU rendering): {why}"), + Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), None => "none (CPU rendering)".into(), }), ), @@ -1095,8 +1095,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option "No new presets".to_string(), - (n, 0) => format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }), - (n, f) => format!("Imported {n} preset{}, {f} file{} not readable", if n == 1 { "" } else { "s" }, if f == 1 { "" } else { "s" }), + (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n), + (n, f) => crate::i18n::tr_format!( + "Imported {n} preset{}, {f} file{} not readable", + if n == 1 { "" } else { "s" }, + if f == 1 { "" } else { "s" }, + f = f, + n = n + ), }; // settings with no counterpart here (the other editor's profiles, masks…) let mut skipped: Vec<&str> = v["imported"] @@ -1141,7 +1147,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option 0 { - msg += &format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }); + msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); } app.toast(&ctx, msg); } @@ -1183,7 +1189,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { app.merge.last_result = Some(v); - app.toast(ctx, format!("{what} merge added")); + app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what)); } Err(e) => { app.ui.status = e.to_string(); - app.toast(ctx, format!("{what} merge failed: {e}")); + app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)); } } } Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); let now = ctx.input(|i| i.time); - app.ui.toast = Some((format!("Merging… {stage} {:.0}%", f * 100.0), now + 0.5)); + app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5)); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } @@ -299,7 +299,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) ui.painter().text( rect.center_bottom() - egui::vec2(0.0, 14.0), egui::Align2::CENTER_CENTER, - format!("{stage}… {:.0}%", f * 100.0), + crate::i18n::tr_format!("{stage}… {:.0}%", f * 100.0, stage = stage), t.font(12.0), t.text, ); @@ -330,7 +330,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) ui.vertical(|ui| { ui.set_width(220.0); if opts.is_pano() { - ui.label(egui::RichText::new("Projection").color(t.text_label)); + ui.label(egui::RichText::new(crate::i18n::tr("Projection")).color(t.text_label)); for (v, l) in [("auto", "Auto Select"), ("spherical", "Spherical"), ("cylindrical", "Cylindrical"), ("perspective", "Perspective")] { if ui.radio(opts.projection == v, l).clicked() { opts.projection = v.to_string(); @@ -341,13 +341,13 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) if num(ui, &BOUNDARY_WARP, &mut bw) { opts.boundary_warp = bw.round(); } - ui.checkbox(&mut opts.fill_edges, "Fill Edges"); - ui.checkbox(&mut opts.auto_crop, "Auto Crop"); + ui.checkbox(&mut opts.fill_edges, crate::i18n::tr("Fill Edges")); + ui.checkbox(&mut opts.auto_crop, crate::i18n::tr("Auto Crop")); ui.add_space(4.0); } if opts.is_hdr() { - ui.checkbox(&mut opts.align, "Auto Align"); - ui.label(egui::RichText::new("Deghost Amount").color(t.text_label)); + ui.checkbox(&mut opts.align, crate::i18n::tr("Auto Align")); + ui.label(egui::RichText::new(crate::i18n::tr("Deghost Amount")).color(t.text_label)); ui.horizontal(|ui| { for (i, (v, l)) in [("none", "None"), ("low", "Low"), ("medium", "Med"), ("high", "High")].iter().enumerate() { if crate::widgets::text_button(ui, &format!("mergeDeghost-{i}"), l, opts.deghost == *v).clicked() { @@ -356,7 +356,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) } }); if opts.command == "merge.hdr" { - ui.add_enabled_ui(opts.deghost != "none", |ui| ui.checkbox(&mut opts.show_overlay, "Show Deghost Overlay")); + ui.add_enabled_ui(opts.deghost != "none", |ui| ui.checkbox(&mut opts.show_overlay, crate::i18n::tr("Show Deghost Overlay"))); } if opts.command == "merge.hdrPanorama" { let mut b = opts.bracket as f64; @@ -366,10 +366,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) } ui.add_space(4.0); } - ui.checkbox(&mut opts.auto_settings, "Auto Settings"); - ui.checkbox(&mut opts.stack, "Create Stack"); + ui.checkbox(&mut opts.auto_settings, crate::i18n::tr("Auto Settings")); + ui.checkbox(&mut opts.stack, crate::i18n::tr("Create Stack")); ui.add_space(8.0); - ui.label(egui::RichText::new(format!("{} photos", app.merge.ids.len())).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{} photos", app.merge.ids.len())).color(t.text_dim)); }); }); } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index a904c87c..4e0f9176 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -104,7 +104,13 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); register(ui.ctx(), "button:copySettings", copy_r); ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); - ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled }); + ui.painter().text( + copy_r.center(), + Align2::CENTER_CENTER, + crate::i18n::tr(label), + t.font(13.0), + if active.is_some() { t.text_label } else { t.text_disabled }, + ); if cresp.clicked() && active.is_some() { let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) }; let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" }; @@ -168,7 +174,7 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { Zoom::Fill => "Fill".to_string(), Zoom::Percent(p) => format!("{p}%"), }; - let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label); + let zr = crate::widgets::dropdown(&mut child, "zoom", crate::i18n::tr(&zoom_label), t.font(13.0), t.text_label); egui::Popup::menu(&zr).show(|ui| { for (label, z) in [ ("Fit", Zoom::Fit), @@ -185,7 +191,13 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { } }); child.add_space(6.0); - let cz = crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label); + let cz = crate::widgets::dropdown( + &mut child, + "clickZoom", + &crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), + t.font(13.0), + t.text_label, + ); egui::Popup::menu(&cz).show(|ui| { for pct in crate::state::CLICK_ZOOMS { if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() { @@ -216,7 +228,7 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { use lightcraft_catalog::GroupBy; use lightcraft_catalog::SortKey::*; let cur = app.session.sort; - ui.label(egui::RichText::new("Sort by").weak()); + ui.label(egui::RichText::new(crate::i18n::tr("Sort by")).weak()); for (label, key, k) in [ ("Capture Date", CaptureDate, "captureDate"), ("Import Date", ImportDate, "importDate"), @@ -225,19 +237,19 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("Rating", Rating, "rating"), ("File Size", FileSize, "fileSize"), ] { - if ui.selectable_label(cur.key == key, label).clicked() { + if ui.selectable_label(cur.key == key, crate::i18n::tr(label)).clicked() { let _ = app.run("library.sort", json!({"key": k})); } } ui.separator(); - if ui.selectable_label(cur.ascending, "Ascending").clicked() { + if ui.selectable_label(cur.ascending, crate::i18n::tr("Ascending")).clicked() { let _ = app.run("library.sort", json!({"ascending": true})); } - if ui.selectable_label(!cur.ascending, "Descending").clicked() { + if ui.selectable_label(!cur.ascending, crate::i18n::tr("Descending")).clicked() { let _ = app.run("library.sort", json!({"ascending": false})); } ui.separator(); - ui.label(egui::RichText::new("Group by date").weak()); + ui.label(egui::RichText::new(crate::i18n::tr("Group by date")).weak()); for (label, g, k) in [ ("Automatic", GroupBy::Auto, "auto"), ("Day", GroupBy::Day, "day"), @@ -245,7 +257,7 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("Year", GroupBy::Year, "year"), ("None", GroupBy::None, "none"), ] { - if ui.selectable_label(cur.group == g, label).clicked() { + if ui.selectable_label(cur.group == g, crate::i18n::tr(label)).clicked() { let _ = app.run("library.sort", json!({"group": k})); } } diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index a5aae79a..031ee28e 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -18,8 +18,8 @@ const TAIL: f32 = 170.0; /// "14 of 120 photos" when a filter narrows the source, else "14 photos". pub fn count_text(matching: usize, total: Option, filtering: bool) -> String { match total { - Some(t) if filtering && t != matching => format!("{matching} of {t} photos"), - _ => format!("{matching} photos"), + Some(t) if filtering && t != matching => crate::i18n::tr_format!("{matching} of {t} photos", matching = matching, t = t), + _ => crate::i18n::tr_format!("{matching} photos", matching = matching), } } @@ -53,7 +53,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { let text_r = Rect::from_min_max(r.min, pos2(r.right() - 22.0, r.max.y)); ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &c.label, font.clone(), t.text); let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0)); - let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text("Remove this filter"); + let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter")); register(ui.ctx(), format!("chip:{i}"), xr); let col = if resp.hovered() { t.text } else { t.text_dim }; let m = 3.5; @@ -80,7 +80,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { let more = crate::widgets::text_button(&mut child, "chipsMore", &format!("+{} more", hidden.len()), false); egui::Popup::menu(&more).show(|ui| { for c in hidden { - if ui.button(format!("{} ✕", c.label)).on_hover_text("Remove this filter").clicked() { + if ui.button(format!("{} ✕", c.label)).on_hover_text(crate::i18n::tr("Remove this filter")).clicked() { let _ = app.run("library.filter", c.clear.clone()); if c.clear.get("text").is_some() { app.ui.search.clear(); @@ -89,7 +89,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { } }); } - if crate::widgets::text_button(&mut child, "chipsClearAll", "Clear all", false).clicked() { + if crate::widgets::text_button(&mut child, "chipsClearAll", crate::i18n::tr("Clear all"), false).clicked() { app.ui.search.clear(); let _ = app.run("library.clearFilter", json!({})); } diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 04b072df..07abae24 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -263,10 +263,10 @@ pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit }; } resp.context_menu(|ui| { - if ui.button("Swap").clicked() { + if ui.button(crate::i18n::tr("Swap")).clicked() { let _ = swap(app); } - if ui.button("Make Candidate the Select").clicked() { + if ui.button(crate::i18n::tr("Make Candidate the Select")).clicked() { let _ = make_select(app); } ui.separator(); @@ -321,7 +321,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { // remove from the survey (deselect) on hover if resp.hovered() && photos.len() > 1 { let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0)); - let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text("Remove from survey"); + let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text(crate::i18n::tr("Remove from survey")); ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 })); paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text); if xresp.clicked() { @@ -331,8 +331,11 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); } let n = photos.len(); - let msg = - if app.session.selection.ids.len() > SURVEY_MAX { format!("Showing {n} of {}", app.session.selection.ids.len()) } else { String::new() }; + let msg = if app.session.selection.ids.len() > SURVEY_MAX { + crate::i18n::tr_format!("Showing {n} of {}", app.session.selection.ids.len(), n = n) + } else { + String::new() + }; if !msg.is_empty() { ui.painter().text(pos2(canvas.right() - 16.0, canvas.top() + 10.0), Align2::RIGHT_TOP, msg, t.font(12.0), t.text_dim); } @@ -357,7 +360,7 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.image_rect = Some(img); pan(app, &resp, img); lresp.context_menu(|ui| { - if ui.button("Clear Reference").clicked() { + if ui.button(crate::i18n::tr("Clear Reference")).clicked() { app.ui.reference = None; app.ui.view = ViewMode::Detail; } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index df5ca4f2..a8fbc638 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -267,7 +267,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // soft proofing: a paper-white surround and the proof's name, as Lightroom shows it if app.ui.soft_proof && !fullscreen { p.rect_filled(canvas, 0.0, Color32::from_gray(238)); - let label = format!("Proof Preview · {}", app.ui.proof.space.label()); + let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label()); p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); } let shown; @@ -275,11 +275,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); draw(Slot::Before, br); shown = draw(Slot::Main, img_rect); - p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text_dim); - p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, "After", t.font(12.0), t.text_dim); + p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); + p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); } else if show_before { shown = draw(Slot::Before, img_rect); - p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text); + p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text); } else if let Some((key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id && t.key == *key) { @@ -294,11 +294,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if shown == "none" { match app.renderer.failure(Slot::Main) { Some(e) => { - p.text(canvas.center() - vec2(0.0, 10.0), Align2::CENTER_CENTER, "This photo can't be opened", t.semibold(14.0), t.text); + p.text( + canvas.center() - vec2(0.0, 10.0), + Align2::CENTER_CENTER, + crate::i18n::tr("This photo can't be opened"), + t.semibold(14.0), + t.text, + ); p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); } None => { - p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim); + p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); } } } @@ -354,7 +360,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { preview_only_pill(ui, canvas, why); } resp.context_menu(|ui| { - ui.menu_button("Zoom", |ui| { + ui.menu_button(crate::i18n::tr("Zoom"), |ui| { for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { if ui.button(label).clicked() { let _ = app.run(cmd, json!({})); @@ -419,7 +425,7 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { let t = Tokens::get(ui.ctx()); let p = ui.painter_at(canvas); - let text = format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason)); + let text = crate::i18n::tr_format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason)); let g = p.layout_no_wrap(text, t.font(12.0), Color32::WHITE); let size = vec2(g.size().x + 40.0, 26.0); let r = Rect::from_min_size(pos2(canvas.center().x - size.x / 2.0, canvas.top() + 12.0), size); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 31ffb786..6f94330e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -99,7 +99,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } .to_string(); let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); - let shown = egui::Window::new(title) + let shown = egui::Window::new(crate::i18n::tr(&title)).id(egui::Id::new("lightcraft-dialog")) .collapsible(false) .resizable(false) .frame(frame) @@ -114,7 +114,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.spacing_mut().item_spacing.y = 8.0; match &mut dlg { Dialog::AutoStack { gap } => { - ui.label(egui::RichText::new("Stack photos taken within this time of each other:").color(t.text_label)); + ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); ui.add( egui::Slider::new(gap, 0.0..=86400.0) .logarithmic(true) @@ -130,15 +130,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::Cull { reject_below, pick_best } => { let n = app.session.selection.ids.len(); - let scope = if n > 1 { format!("the {n} selected photos") } else { format!("the {} photos in view", app.session.visible_cloned().len()) }; + let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) }; ui.label(egui::RichText::new(format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).")).color(t.text_label)); ui.add_space(6.0); let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text("Reject below focus").step_by(1.0)); crate::widgets::register(ui.ctx(), "field:cullReject", r.rect); ui.label(egui::RichText::new(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." }).color(t.text_dim)); - let r = ui.checkbox(pick_best, "Pick the sharpest photo of each burst"); + let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst")); crate::widgets::register(ui.ctx(), "check:cullPick", r.rect); - ui.label(egui::RichText::new("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim)); } Dialog::WhatsNew => { egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { @@ -167,14 +167,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.end_row(); } }); - if ui.button("Copy to Clipboard").clicked() { + if ui.button(crate::i18n::tr("Copy to Clipboard")).clicked() { let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect(); ui.ctx().copy_text(text); } } Dialog::AllMetadata { title, rows, search } => { ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); - let r = ui.add(egui::TextEdit::singleline(search).hint_text("Filter fields").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)); crate::widgets::register(ui.ctx(), "field:metadataSearch", r.rect); let q = search.trim().to_lowercase(); let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); @@ -217,7 +217,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); } Dialog::SmartRules { name, rules, .. } => { - let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); crate::widgets::register(ui.ctx(), "field:smartName", r.rect); ui.add_space(6.0); egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { @@ -230,22 +230,22 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label( egui::RichText::new(match problems.first() { Some(p) => p.clone(), - None => format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }), + None => crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n), }) .color(t.text_dim), ); } Dialog::NewSmartAlbum { name } => { - let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); r.request_focus(); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { confirm = true; } let rules = app.session.view_rules(); let n = app.session.catalog.query(&rules, &Default::default()).len(); - ui.label(egui::RichText::new(format!("Matches: {}", rules.describe())).color(t.text_label)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", rules.describe())).color(t.text_label)); ui.label( - egui::RichText::new(format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" })) + egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) .color(t.text_dim), ); } @@ -299,7 +299,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } if n > 1 { - ui.label(egui::RichText::new(format!("All {n} selected photos move by the same amount.")).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim)); } } Dialog::LabelNames { names, save_as } => { @@ -325,17 +325,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); } field(ui, "Save as set", |ui| { - let te = ui.add(egui::TextEdit::singleline(save_as).hint_text("Optional name").desired_width(f32::INFINITY)); + let te = ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)); crate::widgets::register(ui.ctx(), "field:labelSetName", te.rect); }); ui.label( - egui::RichText::new("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.") + egui::RichText::new(crate::i18n::tr("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.")) .color(t.text_dim), ); } Dialog::Rename { template, start } => { let n = app.session.targets(&json!({})).len(); - ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); let template_id = egui::Id::new("rename-template"); let tags_open = field(ui, "Template", |ui| { ui.spacing_mut().item_spacing.x = 4.0; @@ -360,7 +360,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); ui.label( - egui::RichText::new("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…") + egui::RichText::new(crate::i18n::tr("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")) .color(t.text_dim), ); let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize); @@ -381,12 +381,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } let more = total.saturating_sub(RENAME_PREVIEW_ROWS); if more > 0 { - ui.label(egui::RichText::new(format!("… and {more} more")).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); } } Dialog::RenameKeyword { from, to } => { let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); - let r = ui.add(egui::TextEdit::singleline(to).hint_text("New name").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(to).hint_text(crate::i18n::tr("New name")).desired_width(f32::INFINITY)); crate::widgets::register(ui.ctx(), "field:keywordName", r.rect); r.request_focus(); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -402,7 +402,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::MergeKeywords { from, into } => { ui.label(egui::RichText::new(format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))).color(t.text_label)); - let r = ui.add(egui::TextEdit::singleline(into).hint_text("Keyword").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(into).hint_text(crate::i18n::tr("Keyword")).desired_width(f32::INFINITY)); crate::widgets::register(ui.ctx(), "field:keywordInto", r.rect); r.request_focus(); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -431,23 +431,23 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { - let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); r.request_focus(); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { confirm = true; } } Dialog::CreatePreset { name, group, groups } => { - ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY)); - ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY)); - ui.label(egui::RichText::new("Settings to include").color(t.text_dim)); + ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)); + ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)); + ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); group_checklist(ui, "presetInclude", groups); } Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), Dialog::PasteSettings { groups } => { let n = app.session.targets(&json!({})).len(); ui.label( - egui::RichText::new(format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" })) + egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n)) .color(t.text_dim), ); group_checklist(ui, "pasteGroup", groups); @@ -455,11 +455,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; let n = app.session.selection.ids.len().max(1); - ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); // Preset: load a built-in or saved set of options into the dialog field(ui, "Preset", |ui| { let mut chosen = None; - let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text("Choose…").show_ui(ui, |ui| { + let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { let mut builtin = true; for (p, b) in app.session.all_export_presets() { if builtin && !b { @@ -488,7 +488,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui, "Format", "exportFormat", - &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original")], + &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")], &mut opts.format, ); if !opts.format.is_rendered() { @@ -512,7 +512,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } if !rendered { } else if opts.format == F::Avif { - ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); } else { use lightcraft_engine::export::OutputSpace as C; choices( @@ -571,10 +571,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { &mut opts.metadata, ); if !matches!(opts.metadata, M::None | M::Copyright) { - ui.checkbox(&mut opts.remove_location, "Remove location info"); + ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); } let mut wm_on = opts.watermark.is_some(); - if ui.checkbox(&mut wm_on, "Watermark").changed() { + if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); } if let Some(wm) = &mut opts.watermark { @@ -592,7 +592,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { field(ui, "Graphic", |ui| { trailing_button_row(ui, |ui| { if app.services.pick_files.is_some() - && crate::widgets::text_button(ui, "exportWmChoose", "Choose…", false).clicked() + && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) { wm.image = f; @@ -605,7 +605,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { wm.image_width = (width / 100.0) as f32; } } else { - choices(ui, app.ui.language.tr("Orientation"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal")), (true, app.ui.language.tr("Vertical"))], &mut wm.vertical); + choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical); field(ui, "Text", |ui| { ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text("© Your Name").desired_width(f32::INFINITY)) }); @@ -626,7 +626,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { wm.opacity = (op / 100.0) as f32; } if !graphic { - ui.checkbox(&mut wm.shadow, "Shadow"); + ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); } } } @@ -659,7 +659,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { field(ui, "Folder", |ui| { trailing_button_row(ui, |ui| { if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "exportChooseFolder", "Choose…", false).clicked() + && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() && let Some(pick) = app.services.pick_folder.as_mut() && let Some(d) = pick() { @@ -669,7 +669,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); }); field(ui, "Subfolder", |ui| { - ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text("none").desired_width(f32::INFINITY)) + ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)) }); use lightcraft_engine::export::Conflict as K; choices( @@ -683,17 +683,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { field(ui, "Save preset", |ui| { trailing_button_row(ui, |ui| { let named = !preset_name.trim().is_empty(); - if crate::widgets::text_button(ui, "exportSavePreset", "Save", false).clicked() && named { + if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { let params = export_dialog_params(opts, *full_size, resize, *limit_kb); match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { Ok(_) => { - app.toast(ui.ctx(), format!("Saved export preset “{}”", preset_name.trim())); + app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); preset_name.clear(); } Err(e) => app.toast(ui.ctx(), e), } } - ui.add(egui::TextEdit::singleline(preset_name).hint_text("Preset name").desired_width(ui.available_width())); + ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())); }); }); } @@ -701,14 +701,14 @@ 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 { format!("these {count} photos") }; - ui.label(format!("Move {what} to Recently Deleted?")); - ui.label(egui::RichText::new("They can be restored from Recently Deleted until it is emptied.").color(t.text_dim)); + let what = if *count == 1 { "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)); } Dialog::About => { ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); - ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); - ui.label("MIT OR Apache-2.0. Font: Inter (OFL). Icons: original."); + ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); + ui.label("MIT OR Apache-2.0. Fonts: Inter / BIZ UDPGothic (OFL). Icons: original."); 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)) .fill(t.accent) @@ -735,7 +735,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { for (id, label, sc, _) in crate::menus::UI_COMMANDS { if let Some(sc) = sc { - ui.label(*label); + ui.label(crate::i18n::tr(label)); ui.label(*sc); ui.label(egui::RichText::new(*id).color(t.text_dim)); ui.end_row(); @@ -743,7 +743,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } for c in lightcraft_engine::command_specs() { if let Some(sc) = c.shortcut { - ui.label(c.label); + ui.label(crate::i18n::tr(c.label)); ui.label(sc); ui.label(egui::RichText::new(c.id).color(t.text_dim)); ui.end_row(); @@ -756,7 +756,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .map(|c| c.1) .or_else(|| lightcraft_engine::find_command(id).map(|c| c.label)) .unwrap_or(id); - ui.label(label); + ui.label(crate::i18n::tr(label)); ui.label(*sc); ui.label(egui::RichText::new(*id).color(t.text_dim)); ui.end_row(); @@ -768,7 +768,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.add_space(4.0); ui.horizontal(|ui| { let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. }); - if !informational && ui.button("Cancel").clicked() { + if !informational && ui.button(crate::i18n::tr("Cancel")).clicked() { close = true; } let add_label; @@ -784,7 +784,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { _ if informational => "Close", _ => "OK", }; - if ui.button(ok).clicked() { + if ui.button(crate::i18n::tr(ok)).clicked() { if informational { close = true; } else { @@ -983,15 +983,15 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex (R::Megapixels, "Megapixels"), (R::Percent, "Percentage"), ]; - let r_full = ui.checkbox(full, "Full size"); + let r_full = ui.checkbox(full, crate::i18n::tr("Full size")); crate::widgets::register(ui.ctx(), "check:exportFullSize", r_full.rect); if !*full { field(ui, "Resize to", |ui| { let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); let before = r.mode; - let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(cur).show_ui(ui, |ui| { + let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (m, l) in MODES { - ui.selectable_value(&mut r.mode, m, l); + ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); } }); crate::widgets::register(ui.ctx(), "combo:exportResizeMode", c.response.rect); @@ -1024,7 +1024,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex r.height = h as u32; } } - let c = ui.checkbox(&mut r.dont_enlarge, "Don't enlarge"); + let c = ui.checkbox(&mut r.dont_enlarge, crate::i18n::tr("Don't enlarge")); crate::widgets::register(ui.ctx(), "check:exportDontEnlarge", c.rect); } let mut p = *ppi as f64; @@ -1053,7 +1053,7 @@ fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R ui.horizontal(|ui| { ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(LABEL_W); - ui.label(egui::RichText::new(label).color(t.text_label)); + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); }); add(ui) }) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index e2ecca26..188f3ea5 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -87,20 +87,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } // header: Edit + Auto / B&W / HDR let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, "Edit", t.semibold(15.0), t.text); + ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, crate::i18n::tr("Edit"), t.semibold(15.0), t.text); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; - if text_button(ui, "auto", "Auto", false).clicked() { + if text_button(ui, "auto", crate::i18n::tr("Auto"), false).clicked() { let _ = app.run("develop.auto", json!({})); app.toast(ui.ctx(), "Auto settings applied"); } let bw = crate::is_bw(&d); - if text_button(ui, "bw", "B&W", bw).clicked() { + if text_button(ui, "bw", crate::i18n::tr("B&W"), bw).clicked() { let _ = app.run("develop.treatment", json!({})); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "reset", "Reset", false).on_hover_text("Reset all edits (Cmd+Shift+R)").clicked() { + if text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr("Reset all edits (Cmd+Shift+R)")).clicked() + { let _ = app.run("develop.reset", json!({})); } }); @@ -118,9 +119,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.session.auto_sync && n > 1 { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("Auto Sync: edits apply to {n} photos")).color(t.accent).size(12.0)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("Auto Sync: edits apply to {n} photos", n = n)).color(t.accent).size(12.0)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "autoSyncOff", "Turn Off", false).clicked() { + if text_button(ui, "autoSyncOff", crate::i18n::tr("Turn Off"), false).clicked() { let _ = app.run("develop.autoSync", json!({"on": false})); } }); @@ -131,9 +132,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // profile row egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new("Profile").font(t.font(13.0)).color(t.text_dim)); + 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", name, t.font(15.0), t.text_label); + let r = + crate::widgets::dropdown(ui, "profile", if name == "Color" { crate::i18n::tr("Color") } else { 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() { @@ -154,7 +156,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } ui.add_space(6.0); let open = app.ui.flyout_open("curve"); - if flyout_row(ui, "curve", "Curve", Icon::Curve, open).clicked() { + if flyout_row(ui, "curve", crate::i18n::tr("Curve"), Icon::Curve, open).clicked() { app.ui.toggle_flyout("curve"); } if open { @@ -166,8 +168,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // White balance row egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new("White Balance").font(t.font(13.0)).color(t.text_dim)); - let r = crate::widgets::dropdown(ui, "wbMode", d.wb.mode.label(), t.font(14.0), t.text_label); + ui.label(egui::RichText::new(crate::i18n::tr("White Balance")).font(t.font(13.0)).color(t.text_dim)); + let r = crate::widgets::dropdown(ui, "wbMode", crate::i18n::tr(d.wb.mode.label()), t.font(14.0), t.text_label); egui::Popup::menu(&r).show(|ui| { for m in WbMode::ALL { if m == WbMode::Custom { @@ -210,7 +212,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } if !crate::is_bw(d) { let open = app.ui.flyout_open("pointColor"); - if flyout_row(ui, "pointColor", "Point Color", Icon::Picker, open).clicked() { + if flyout_row(ui, "pointColor", crate::i18n::tr("Point Color"), Icon::Picker, open).clicked() { app.ui.toggle_flyout("pointColor"); } if open { @@ -218,7 +220,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } let open = app.ui.flyout_open("grading"); - if flyout_row(ui, "grading", "Color Grading", Icon::Presets, open).clicked() { + if flyout_row(ui, "grading", crate::i18n::tr("Color Grading"), Icon::Presets, open).clicked() { app.ui.toggle_flyout("grading"); } if open { @@ -230,7 +232,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { for c in ["effects.texture", "effects.clarity", "effects.dehaze"] { control(app, ui, d, c, true); } - sub_title(ui, "Vignette"); + sub_title(ui, crate::i18n::tr("Vignette")); { use lightcraft_develop::VignetteStyle as V; let styles = [ @@ -255,7 +257,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let enabled = c == "vignette.amount" || d.vignette.amount != 0.0; control(app, ui, d, c, enabled); } - sub_title(ui, "Grain"); + sub_title(ui, crate::i18n::tr("Grain")); for c in ["grain.amount", "grain.size", "grain.roughness"] { control(app, ui, d, c, c == "grain.amount" || d.grain.amount != 0.0); } @@ -265,7 +267,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.sharpenMasking"] { control(app, ui, d, c, c == "detail.sharpenAmount" || d.detail.sharpen_amount > 0.0); } - sub_title(ui, "Noise Reduction"); + sub_title(ui, crate::i18n::tr("Noise Reduction")); for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast", "detail.nrColor", "detail.nrColorDetail", "detail.nrColorSmoothness"] { control(app, ui, d, c, true); @@ -276,16 +278,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let has_lens = app.session.catalog.photo(id).is_some_and(|p| p.embedded_lens.is_some()); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { let mut ca = d.optics.remove_ca; - if ui.checkbox(&mut ca, "Remove Chromatic Aberration").changed() { + if ui.checkbox(&mut ca, crate::i18n::tr("Remove Chromatic Aberration")).changed() { let _ = app.run("develop.merge", json!({"settings": {"optics": {"remove_ca": ca}}, "label": "Remove CA"})); } let mut lp = d.optics.lens_profile; - if ui.checkbox(&mut lp, "Enable Lens Corrections").changed() { + if ui.checkbox(&mut lp, crate::i18n::tr("Enable Lens Corrections")).changed() { let _ = app.run("develop.merge", json!({"settings": {"optics": {"lens_profile": lp}}, "label": "Lens Corrections"})); } if lp && !has_lens { ui.label( - egui::RichText::new("No lens data embedded in this file (DNG lens opcodes only).") + egui::RichText::new(crate::i18n::tr("No lens data embedded in this file (DNG lens opcodes only).")) .size(11.0) .color(Tokens::get(ui.ctx()).text_dim), ); @@ -296,14 +298,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { control(app, ui, d, c, has_lens); } } - sub_title(ui, "Manual"); + sub_title(ui, crate::i18n::tr("Manual")); for c in ["optics.distortion", "optics.vignetting"] { control(app, ui, d, c, true); } control(app, ui, d, "optics.vignettingMidpoint", d.optics.vignetting != 0.0); ui.add_space(6.0); let open = app.ui.flyout_open("defringe"); - if flyout_row(ui, "defringe", "Defringe", Icon::Picker, open).clicked() { + if flyout_row(ui, "defringe", crate::i18n::tr("Defringe"), Icon::Picker, open).clicked() { app.ui.toggle_flyout("defringe"); } if open { @@ -315,7 +317,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let on = d.optics.defringe_green_amount > 0.0; control(app, ui, d, "optics.defringeGreenHueLo", on); control(app, ui, d, "optics.defringeGreenHueHi", on); - sub_title(ui, "Lateral Chromatic Aberration"); + sub_title(ui, crate::i18n::tr("Lateral Chromatic Aberration")); control(app, ui, d, "optics.caRed", true); control(app, ui, d, "optics.caBlue", true); } @@ -323,7 +325,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); // Lightroom Classic's Calibration panel (the cloud app hides it): last, like there. section(app, ui, &d, "calibration", "Calibration", |app, ui, d| { - sub_title(ui, "Shadows"); + sub_title(ui, crate::i18n::tr("Shadows")); control(app, ui, d, "calibration.shadowsTint", true); for (title, k) in [("Red Primary", "red"), ("Green Primary", "green"), ("Blue Primary", "blue")] { sub_title(ui, title); @@ -383,14 +385,18 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.separator(); if let Some(p) = profile(cur) { let fav = app.session.profile_favorites.iter().any(|f| f == p.id); - let label = if fav { format!("Remove “{}” from Favorites", p.name) } else { format!("Add “{}” to Favorites", p.name) }; + let label = if fav { + crate::i18n::tr_format!("Remove “{}” from Favorites", p.name) + } else { + crate::i18n::tr_format!("Add “{}” to Favorites", p.name) + }; let r = ui.button(label); register(ui.ctx(), "profileMenu:toggleFavorite", r.rect); if r.clicked() { let _ = app.run("profile.favorite", json!({"id": p.id})); } } - let r = ui.button("Browse…"); + let r = ui.button(crate::i18n::tr("Browse…")); register(ui.ctx(), "profileMenu:browse", r.rect); if r.clicked() { let _ = app.run("panel.profiles", json!({})); @@ -402,7 +408,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: format!("Profile: {}", p.name), settings: s }); + app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s }); } if let Some(id) = pick.0 { let _ = app.run("develop.profile", json!({"id": id})); @@ -412,7 +418,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) pub fn sub_title(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); - ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); + ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label); } fn section( @@ -531,16 +537,17 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 6 }).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(RichText::new("Soft Proofing").color(t.text).strong()); + ui.label(RichText::new(crate::i18n::tr("Soft Proofing")).color(t.text).strong()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut dest = app.ui.proof.dest_warning; let r = ui .toggle_value(&mut dest, RichText::new("■").color(Color32::from_rgb(255, 40, 40))) - .on_hover_text("Show destination gamut warning"); + .on_hover_text(crate::i18n::tr("Show destination gamut warning")); register(ui.ctx(), "button:proofDestWarning", r.rect); let mut disp = app.ui.proof.display_warning; - let r2 = - ui.toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255))).on_hover_text("Show display gamut warning"); + let r2 = ui + .toggle_value(&mut disp, RichText::new("■").color(Color32::from_rgb(40, 90, 255))) + .on_hover_text(crate::i18n::tr("Show display gamut warning")); register(ui.ctx(), "button:proofDisplayWarning", r2.rect); if dest != app.ui.proof.dest_warning || disp != app.ui.proof.display_warning { let _ = app.run("view.softProof", json!({"destWarning": dest, "displayWarning": disp})); @@ -548,7 +555,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); ui.horizontal(|ui| { - ui.label(RichText::new("Profile:").color(t.text_label)); + ui.label(RichText::new(crate::i18n::tr("Profile:")).color(t.text_label)); let cur = app.ui.proof.space; egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(cur.label()).show_ui(ui, |ui| { for s in OutputSpace::ALL { @@ -558,10 +565,10 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); }); - let r = ui.button("Create Proof Copy"); + let r = ui.button(crate::i18n::tr("Create Proof Copy")); register(ui.ctx(), "button:createProofCopy", r.rect); if r.clicked() { - let name = format!("Proof Copy ({})", app.ui.proof.space.label()); + let name = crate::i18n::tr_format!("Proof Copy ({})", app.ui.proof.space.label()); if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() { app.toast(ui.ctx(), "Proof copy created"); } @@ -605,7 +612,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev "green" => "Green channel", _ => "Blue channel", }; - let resp = resp.on_hover_text(format!("{name} — double-click to reset it")); + let resp = resp.on_hover_text(crate::i18n::tr_format!("{name} — double-click to reset it", name = name)); if resp.double_clicked() { app.ui.curve_channel = ch.into(); let _ = app.run("curve.reset", json!({"channel": ch})); @@ -796,7 +803,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) let t = Tokens::get(ui.ctx()); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(RichText::new("Point Curve").font(t.font(12.0)).color(t.text_label)); + 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); egui::Popup::menu(&r).show(|ui| { @@ -816,7 +823,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } if !p.builtin { r.context_menu(|ui| { - if ui.button("Delete Preset").clicked() { + if ui.button(crate::i18n::tr("Delete Preset")).clicked() { let _ = app.run("curve.deletePreset", json!({"name": p.name})); ui.close(); } @@ -825,28 +832,29 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } ui.separator(); let shaped = current.as_deref() != Some("Linear"); - let r = ui.add_enabled(shaped, egui::Button::new("Save Point Curve…")); + let r = ui.add_enabled(shaped, egui::Button::new(crate::i18n::tr("Save Point Curve…"))); register(ui.ctx(), "curvePresetMenu:save", r.rect); if r.clicked() { crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name"); ui.close(); } - if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new("Import Presets…")).clicked() { + if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { let _ = app.run("file.importCurvePresets", json!({})); ui.close(); } let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty(); - if ui.add_enabled(can_export, egui::Button::new("Export Presets…")).clicked() { + if ui.add_enabled(can_export, egui::Button::new(crate::i18n::tr("Export Presets…"))).clicked() { let _ = app.run("file.exportCurvePresets", json!({})); ui.close(); } if presets.iter().any(|p| !p.builtin) { - ui.label(RichText::new("Right-click a preset to delete it").font(t.font(11.0)).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = text_button(ui, "curveReset", "Reset", false) - .on_hover_text("Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)"); + let r = text_button(ui, "curveReset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr( + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)", + )); if r.clicked() { let _ = app.run("curve.reset", json!({"channel": "all"})); } @@ -857,14 +865,15 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) /// Right-click menu of the curve graph. fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { - let label = if ch == "parametric" { "Reset Parametric Curve".to_string() } else { format!("Reset {} Channel", channel_label(ch)) }; + let label = + if ch == "parametric" { "Reset Parametric Curve".to_string() } else { crate::i18n::tr_format!("Reset {} Channel", channel_label(ch)) }; let r = ui.button(label); register(ui.ctx(), "curveMenu:resetChannel", r.rect); if r.clicked() { let _ = app.run("curve.reset", json!({"channel": ch})); ui.close(); } - let r = ui.button("Reset All Curves"); + let r = ui.button(crate::i18n::tr("Reset All Curves")); register(ui.ctx(), "curveMenu:resetAll", r.rect); if r.clicked() { let _ = app.run("curve.reset", json!({"channel": "all"})); @@ -925,7 +934,7 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { let tool = format!("tat:{k}"); let active = app.ui.tool == tool; if text_button(ui, &format!("tatMixer-{k}"), label, active) - .on_hover_text("Targeted adjustment: drag up/down on the photo to adjust the colours there") + .on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there")) .clicked() { app.ui.tool = if active { String::new() } else { tool }; @@ -937,7 +946,10 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { if crate::is_bw(d) { ui.horizontal(|ui| { ui.add_space(24.0); - if crate::widgets::text_button(ui, "bwAuto", "Auto", false).on_hover_text("Set the mix from the photo's colours").clicked() { + if crate::widgets::text_button(ui, "bwAuto", crate::i18n::tr("Auto"), false) + .on_hover_text(crate::i18n::tr("Set the mix from the photo's colours")) + .clicked() + { let _ = app.run("develop.autoBwMix", json!({})); } }); @@ -994,25 +1006,25 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) }); if n == 0 { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| { - ui.label(egui::RichText::new("Pick a colour on the photo with the eyedropper.").size(12.0).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Pick a colour on the photo with the eyedropper.")).size(12.0).color(t.text_dim)); }); return; } for k in ["hueShift", "satShift", "lumShift", "variance"] { control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); } - sub_title(ui, "Range"); + sub_title(ui, crate::i18n::tr("Range")); for k in ["range", "hueRange", "satRange", "lumRange"] { control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); } egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| { ui.horizontal(|ui| { let mut v = app.ui.point_color_visualize; - if ui.checkbox(&mut v, "Visualize range").changed() { + if ui.checkbox(&mut v, crate::i18n::tr("Visualize range")).changed() { app.ui.point_color_visualize = v; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "pointColorDelete", "Delete", false).clicked() { + if text_button(ui, "pointColorDelete", crate::i18n::tr("Delete"), false).clicked() { let _ = app.run("pointColor.delete", json!({"index": sel})); } }); @@ -1047,9 +1059,9 @@ fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); ui.painter().circle_filled(dot, 5.0, Color32::WHITE); ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK)); - ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, label, t.font(12.0), t.text_label); + ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, crate::i18n::tr(label), t.font(12.0), t.text_label); if resp.drag_started() { - let _ = app.run("develop.beginInteraction", json!({"label": format!("{label} Grading")})); + let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); } if (resp.dragged() || resp.clicked()) && let Some(q) = resp.interact_pointer_pos() @@ -1093,7 +1105,7 @@ fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { - ui.label(egui::RichText::new(format!("Quick Develop · {n} photos")).color(t.text_label).size(12.5)); + ui.label(egui::RichText::new(crate::i18n::tr_format!("Quick Develop · {n} photos", n = n)).color(t.text_label).size(12.5)); ui.add_space(4.0); let rows: [(&str, &str, f64, f64); 9] = [ ("Exposure", "light.exposure", 1.0 / 3.0, 1.0), @@ -1108,11 +1120,11 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { ]; egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { for (label, ctl, small, big) in rows { - 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)); for (txt, d) in [("◀◀", -big), ("◀", -small), ("▶", small), ("▶▶", big)] { let r = ui.add(egui::Button::new(egui::RichText::new(txt).size(10.0)).min_size(egui::vec2(28.0, 18.0))); crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect); - if r.on_hover_text(format!("{label} {d:+} on every selected photo")).clicked() { + if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = label)).clicked() { let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d})); } } diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 2bef1dd9..c8de2cff 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -33,7 +33,7 @@ fn filter(app: &mut LightcraftApp, patch: Value) { /// A small label before a group of controls. fn caption(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(text).font(t.font(12.0)).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr(text)).font(t.font(12.0)).color(t.text_dim)); } /// A square toggle with an icon or a colour swatch. @@ -59,13 +59,13 @@ fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&e t.icon }, ); - resp.on_hover_text(tip) + resp.on_hover_text(crate::i18n::tr(tip)) } /// A dropdown showing `current`; `items` are (label, filter patch, selected). fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, active: bool, items: Vec<(String, Value, bool)>) { let t = Tokens::get(ui.ctx()); - let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), current, t.font(12.5), if active { t.text } else { t.text_label }); + let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), crate::i18n::tr(current), t.font(12.5), if active { t.text } else { t.text_label }); if active { ui.painter().rect_stroke(r.rect.expand(2.0), 4.0, Stroke::new(1.0, t.accent.gamma_multiply(0.7)), StrokeKind::Outside); } @@ -73,7 +73,7 @@ fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, a egui::Popup::menu(&r).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for (label, patch, sel) in &items { - if ui.selectable_label(*sel, label).clicked() { + if ui.selectable_label(*sel, crate::i18n::tr(label)).clicked() { chosen = Some(patch.clone()); } } @@ -260,19 +260,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // save / clear ui.add_space(14.0); let filtering = app.session.filter != Default::default(); - let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Save as Smart Album…").font(t.font(12.5)))); + let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new(crate::i18n::tr("Save as Smart Album…")).font(t.font(12.5)))); register(ui.ctx(), "button:filterSave", save.rect); if save.clicked() { let _ = app.run("dialog.newSmartAlbum", json!({})); } - let clear = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Clear").font(t.font(12.5)))); + let clear = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new(crate::i18n::tr("Clear")).font(t.font(12.5)))); register(ui.ctx(), "button:filterClear", clear.rect); if clear.clicked() { app.ui.search.clear(); let _ = app.run("library.clearFilter", json!({})); } // filter presets: apply one, save the current filter, delete - let presets = ui.button(egui::RichText::new("Presets ▾").font(t.font(12.5))); + let presets = ui.button(egui::RichText::new(crate::i18n::tr("Presets ▾")).font(t.font(12.5))); register(ui.ctx(), "button:filterPresets", presets.rect); egui::Popup::menu(&presets).show(|ui| { let names: Vec = app.session.filter_presets.iter().map(|f| f.name.clone()).collect(); @@ -282,16 +282,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("filter.applyPreset", json!({"name": n})); } r.context_menu(|ui| { - if ui.button("Delete Preset").clicked() { + if ui.button(crate::i18n::tr("Delete Preset")).clicked() { let _ = app.run("filter.deletePreset", json!({"name": n})); } }); } if names.is_empty() { - ui.label(egui::RichText::new("No filter presets yet").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("No filter presets yet")).color(t.text_dim)); } ui.separator(); - if ui.add_enabled(filtering, egui::Button::new("Save Current Filter as Preset…")).clicked() { + if ui.add_enabled(filtering, egui::Button::new(crate::i18n::tr("Save Current Filter as Preset…"))).clicked() { crate::panels::dialogs::prompt(app, "Save Filter Preset", "Preset name", "", "filter.savePreset", json!({}), "name"); } }); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index dcebe199..760abbfe 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -143,7 +143,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); let total = app.session.source_total(); let counted = super::chips::count_text(ids.len(), total, !chips.is_empty()); - let cnt = if sel_n > 1 { format!("{sel_n} selected · {counted}") } else { counted }; + let cnt = if sel_n > 1 { crate::i18n::tr_format!("{sel_n} selected · {counted}", counted = counted, sel_n = sel_n) } else { counted }; match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) { Some(b) => { let local = app.caches.grid.local(&app.session.catalog, &ids, generation); @@ -163,7 +163,9 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ids.is_empty() { if !chips.is_empty() { let body = match total { - Some(n) if n > 0 => format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog)), + 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) + } _ => "Remove a filter above, or choose Clear all".to_string(), }; super::empty_message(ui, ui.max_rect(), "No photos match the active filters", &body); @@ -412,7 +414,13 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: let gw = g.size().x; p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text); let n = run.count; - p.text(pos2(x + gw + 10.0, r.center().y), Align2::LEFT_CENTER, format!("· {n} photo{}", if n == 1 { "" } else { "s" }), t.font(12.5), t.text_dim); + p.text( + pos2(x + gw + 10.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr_format!("· {n} photo{}", if n == 1 { "" } else { "s" }, n = n), + t.font(12.5), + t.text_dim, + ); if resp.clicked() { let group: Vec = ids[run.start..(run.start + run.count).min(ids.len())].iter().map(|p| p.0).collect(); let m = ui.input(|i| i.modifiers); @@ -571,7 +579,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square if let Some(why) = &photo.preview_only { // a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom // left, above the badge bar (always shown: it changes what editing does) - let g = p.layout_no_wrap("Preview".into(), t.semibold(9.5), Color32::WHITE); + let g = p.layout_no_wrap(crate::i18n::tr("Preview").into(), t.semibold(9.5), Color32::WHITE); let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0); let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0)); p.rect_filled(br, 8.0, Color32::from_black_alpha(170)); @@ -579,7 +587,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); register(ui.ctx(), format!("badge:previewOnly:{}", id.0), br); ui.interact(br, egui::Id::new(("preview-only-badge", id.0)), Sense::hover()) - .on_hover_text(format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); + .on_hover_text(crate::i18n::tr_format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); } if photo.flag == Flag::Reject { p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); @@ -656,18 +664,18 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &light ui.add_space(10.0); // counted once per view (cached); the ids are only gathered on a click if local_n > 0 { - let label = format!("Add {local_n} to My Photos"); + let label = crate::i18n::tr_format!("Add {local_n} to My Photos", local_n = local_n); if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() { let local: Vec = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect(); let n = local.len(); match app.run("photo.addToLibrary", json!({"ids": local})) { - Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" })), + Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" }, n = n)), Err(e) => app.toast(ui.ctx(), e), } } } let mut sub = b.subfolders; - let c = ui.checkbox(&mut sub, "Include subfolders"); + let c = ui.checkbox(&mut sub, crate::i18n::tr("Include subfolders")); register(ui.ctx(), "check:includeSubfolders", c.rect); if c.changed() { let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub})); @@ -690,7 +698,11 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.set_cursor_icon(egui::CursorIcon::Grabbing); egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(Color32::WHITE).font(t.semibold(12.0))); + ui.label( + egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)) + .color(Color32::WHITE) + .font(t.semibold(12.0)), + ); }); }); } @@ -712,11 +724,11 @@ pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.close(); } } - if ui.selectable_label(current.is_none(), "None").clicked() { + if ui.selectable_label(current.is_none(), crate::i18n::tr("None")).clicked() { let _ = app.run("photo.label", json!({"label": "none"})); } ui.separator(); - if ui.button("Edit Label Names…").clicked() { + if ui.button(crate::i18n::tr("Edit Label Names…")).clicked() { let _ = app.run("dialog.labelNames", json!({})); } } @@ -755,10 +767,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if !app.session.selection.contains(id) { let _ = app.run("library.select", json!({"ids": [id.0]})); } - if ui.button("Open in Detail").clicked() { + if ui.button(crate::i18n::tr("Open in Detail")).clicked() { let _ = app.run("view.detail", json!({})); } - if ui.button("Find Similar Photos").clicked() { + if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() { match app.run("library.findSimilar", json!({"id": id.0})) { Ok(r) => { let n = r["photos"].as_array().map_or(0, Vec::len); @@ -768,26 +780,26 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { Err(e) => app.toast(ui.ctx(), e), } } - if ui.button("Set as Reference Photo").clicked() { + if ui.button(crate::i18n::tr("Set as Reference Photo")).clicked() { let _ = app.run("photo.setReference", json!({"id": id.0})); } ui.separator(); - ui.menu_button("Set Rating", |ui| { + 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() { let _ = app.run("photo.rate", json!({"rating": r})); } } }); - ui.menu_button("Set Flag", |ui| { + ui.menu_button(crate::i18n::tr("Set Flag"), |ui| { for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] { if ui.button(l).clicked() { let _ = app.run("photo.flag", json!({"flag": f})); } } }); - ui.menu_button("Set Color Label", |ui| label_menu(app, ui)); - ui.menu_button("Add to Album", |ui| { + ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); + ui.menu_button(crate::i18n::tr("Add to Album"), |ui| { let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); for (aid, name) in albums { if ui.button(name).clicked() { @@ -799,23 +811,23 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source && app.session.catalog.album(aid).is_some_and(|a| !a.folder && !a.is_smart()) { - if ui.button("Remove from Album").clicked() { + if ui.button(crate::i18n::tr("Remove from Album")).clicked() { let _ = app.run("album.removePhotos", json!({"id": aid.0})); } - if ui.button("Set as Album Cover").clicked() { + if ui.button(crate::i18n::tr("Set as Album Cover")).clicked() { let _ = app.run("album.setCover", json!({"id": aid.0, "photo": id.0})); } } - if ui.button("Rename…").clicked() { + if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } - if ui.button("Create Virtual Copy").clicked() { + if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() { let _ = app.run("photo.virtualCopy", json!({})); } - if ui.button("Create Version").clicked() { + if ui.button(crate::i18n::tr("Create Version")).clicked() { let _ = app.run("version.create", json!({})); } - ui.menu_button("Stack", |ui| { + ui.menu_button(crate::i18n::tr("Stack"), |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; for (label, cmd, on) in [ @@ -833,24 +845,24 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } ui.separator(); - if ui.button("Auto-Stack by Capture Time…").clicked() { + if ui.button(crate::i18n::tr("Auto-Stack by Capture Time…")).clicked() { let _ = app.run("dialog.autoStack", json!({})); } }); ui.separator(); - if ui.button("Copy Edit Settings").clicked() { + if ui.button(crate::i18n::tr("Copy Edit Settings")).clicked() { let _ = app.run("develop.copy", json!({})); } - if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new("Paste Edit Settings")).clicked() { + if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new(crate::i18n::tr("Paste Edit Settings"))).clicked() { let _ = app.run("develop.paste", json!({})); } - if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new("Paste Selected Settings…")).clicked() { + if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new(crate::i18n::tr("Paste Selected Settings…"))).clicked() { let _ = app.run("dialog.pasteSettings", json!({})); } - if ui.button("Reset Edits").clicked() { + if ui.button(crate::i18n::tr("Reset Edits")).clicked() { let _ = app.run("develop.reset", json!({})); } - ui.menu_button("Photo Merge", |ui| { + ui.menu_button(crate::i18n::tr("Photo Merge"), |ui| { let n = app.session.targets(&json!({})).len(); for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { if ui.add_enabled(n >= 2, egui::Button::new(label)).clicked() { @@ -859,33 +871,33 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.separator(); - if ui.button("Rotate Left").clicked() { + if ui.button(crate::i18n::tr("Rotate Left")).clicked() { let _ = app.run("photo.rotateLeft", json!({})); } - if ui.button("Rotate Right").clicked() { + if ui.button(crate::i18n::tr("Rotate Right")).clicked() { let _ = app.run("photo.rotateRight", json!({})); } ui.separator(); // the original moved or its drive is gone: point the photo at the file again // (a cached answer, checked off the UI thread) let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if app.session.media.availability.is_offline(path)); - if missing && ui.button("Locate Missing File…").clicked() { + if missing && ui.button(crate::i18n::tr("Locate Missing File…")).clicked() { let _ = app.run("photo.locate", json!({})); } - if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new("Show in Finder")).clicked() { + if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr("Show in Finder"))).clicked() { let _ = app.run("app.showInFinder", json!({})); } - if ui.button("Export…").clicked() { + if ui.button(crate::i18n::tr("Export…")).clicked() { let _ = app.run("dialog.export", json!({})); } - ui.menu_button("Export with Preset", |ui| { + ui.menu_button(crate::i18n::tr("Export with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { match app.run("app.export", json!({"preset": p.name, "background": true})) { Ok(r) if r.get("background").is_some() => {} Ok(r) => { let n = r["files"].as_array().map_or(0, Vec::len); - app.toast(ui.ctx(), format!("Exported {n} photo{}", if n == 1 { "" } else { "s" })); + app.toast(ui.ctx(), crate::i18n::tr_format!("Exported {n} photo{}", if n == 1 { "" } else { "s" }, n = n)); } Err(e) => app.toast(ui.ctx(), e), } @@ -893,7 +905,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.separator(); - if ui.button("Delete Photo").clicked() { + if ui.button(crate::i18n::tr("Delete Photo")).clicked() { let _ = app.run("photo.delete", json!({})); } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 5b96941c..7d703743 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -20,11 +20,12 @@ fn row( selected: bool, indent: f32, ) -> egui::Response { + let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click()); register(ui.ctx(), format!("source:{id}"), r); let name = match count { - Some(n) => format!("{label}, {n} photos"), + Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = label, n = n), None => label.to_string(), }; resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)); @@ -61,7 +62,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |ui| { ui.spacing_mut().item_spacing.y = 0.0; let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text); + ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text); let counts = app.caches.counts(&app.session.catalog); let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| { @@ -85,7 +86,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(10.0); // Albums header let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label); + ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Albums"), t.semibold(13.5), t.text_label); let mut hdr = ui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom())) @@ -93,20 +94,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album"); egui::Popup::menu(&plus).show(|ui| { - if ui.button("Create Album…").clicked() { + if ui.button(crate::i18n::tr("Create Album…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false }); } - if ui.button("Create Smart Album…").clicked() { + if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::SmartRules { id: None, name: String::new(), rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, }); } - if ui.button("Create Smart Album from Filter…").clicked() { + if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() }); } - if ui.button("Create Folder…").clicked() { + if ui.button(crate::i18n::tr("Create Folder…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true }); } }); @@ -116,7 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { local_section(app, ui); // By date let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label); + 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) { @@ -193,7 +194,7 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let t = Tokens::get(ui.ctx()); let (lr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, "Local", t.semibold(13.5), t.text_label); + ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Local"), t.semibold(13.5), t.text_label); let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); let mut builtin: Vec<(String, String)> = Vec::new(); if !home.is_empty() { @@ -232,9 +233,9 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let hidden = app.ui.hidden_locations.len(); if hidden > 0 { - let label = format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }); + let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) - .on_hover_text("Put the locations you removed from Local back (no files change)") + .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")) .clicked() { let _ = app.run("local.restoreHidden", json!({})); @@ -432,20 +433,26 @@ fn folder_tree( } resp.context_menu(|ui| { if (transient || indent > 0.0) - && ui.button(if transient { "Keep in Local" } else { "Add to Local" }).on_hover_text("List this folder in Local from now on").clicked() + && ui + .button(if transient { "Keep in Local" } else { "Add to Local" }) + .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) + .clicked() { let _ = app.run("local.addRoot", json!({"path": path})); ui.close(); } if indent == 0.0 - && ui.button("Remove from Local").on_hover_text("Hides this shortcut only; the folder and its photos stay as they are").clicked() + && ui + .button(crate::i18n::tr("Remove from Local")) + .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) + .clicked() { let _ = app.run("local.hide", json!({"path": path})); ui.close(); } - if ui.button("Rename Folder…").clicked() { + if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: format!("Rename “{name}”"), + title: crate::i18n::tr_format!("Rename “{name}”", name = name), hint: "Folder name (renamed on disk; its photos follow)".into(), value: name.to_string(), command: "folder.rename".into(), @@ -453,7 +460,7 @@ fn folder_tree( key: "name".into(), }); } - if app.services.pick_folder.is_some() && ui.button("Move Folder To…").clicked() { + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { let into = app.services.pick_folder.as_mut().and_then(|f| f()); if let Some(into) = into { match app.run("folder.move", json!({"path": path, "into": into})) { @@ -463,7 +470,7 @@ fn folder_tree( } } if app.services.reveal.is_some() - && ui.button("Show in Finder").clicked() + && ui.button(crate::i18n::tr("Show in Finder")).clicked() && let Some(f) = app.services.reveal.as_mut() { let _ = f(path); @@ -537,7 +544,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent drop_target(app, ui, &resp, a); } if let Some(rules) = &a.smart { - resp = resp.on_hover_text(format!("Smart album: {}", rules.describe())); + resp = resp.on_hover_text(crate::i18n::tr_format!("Smart album: {}", rules.describe())); } if resp.clicked() { let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); @@ -560,7 +567,7 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response if ui.input(|i| i.pointer.any_released()) { let n = ids.len(); match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { - Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name)), + Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n)), Err(e) => app.toast(ui.ctx(), e), } app.ui.dragging_photos = None; @@ -569,27 +576,27 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { resp.context_menu(|ui| { - if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() { + if !a.folder && !a.is_smart() && ui.button(crate::i18n::tr("Add Selected Photos")).clicked() { let _ = app.run("album.addPhotos", json!({"id": a.id.0})); } if !a.folder && !a.is_smart() { let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); - if !is_target && ui.button("Set as Target Album (B adds to it)").clicked() { + if !is_target && ui.button(crate::i18n::tr("Set as Target Album (B adds to it)")).clicked() { let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }})); } - if is_target && !a.quick && ui.button("Stop Using as Target Album").clicked() { + if is_target && !a.quick && ui.button(crate::i18n::tr("Stop Using as Target Album")).clicked() { let _ = app.run("album.setTarget", json!({"id": null})); } } - if a.quick && ui.button("Clear Quick Collection").clicked() { + if a.quick && ui.button(crate::i18n::tr("Clear Quick Collection")).clicked() { let _ = app.run("album.clearQuick", json!({})); } - if a.is_smart() && ui.button("Edit Smart Album…").clicked() { + if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() { // older smart albums keep their filter fields; the editor works on the rule set let rules = a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(); app.ui.dialog = Some(crate::state::Dialog::SmartRules { id: Some(a.id.0), name: a.name.clone(), rules }); } - if a.is_smart() && ui.button("Update Rules from Current Filter").clicked() { + if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() { let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})); } if !a.folder { @@ -599,12 +606,12 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let _ = app.run("library.selectAll", json!({})); }; let has_photos = app.session.catalog.album_count(a.id) > 0; - if ui.add_enabled(has_photos, egui::Button::new("Export Album…")).clicked() { + if ui.add_enabled(has_photos, egui::Button::new(crate::i18n::tr("Export Album…"))).clicked() { show_all(app); let _ = app.run("dialog.export", json!({})); } ui.add_enabled_ui(has_photos, |ui| { - ui.menu_button("Export Album with Preset", |ui| { + ui.menu_button(crate::i18n::tr("Export Album with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { show_all(app); @@ -621,8 +628,8 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let mut folders: Vec<(u64, String)> = app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); folders.sort_by_key(|(_, n)| n.to_lowercase()); - ui.menu_button("Move to", |ui| { - if ui.add_enabled(a.parent.is_some(), egui::Button::new("Top Level")).clicked() { + ui.menu_button(crate::i18n::tr("Move to"), |ui| { + if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() { let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); } for (fid, name) in &folders { @@ -631,10 +638,10 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } } }); - if ui.button("Rename…").clicked() { + if ui.button(crate::i18n::tr("Rename…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() }); } - if ui.button("Delete").clicked() { + if ui.button(crate::i18n::tr("Delete")).clicked() { let _ = app.run("album.delete", json!({"id": a.id.0})); } }); @@ -668,7 +675,7 @@ fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.add_space(10.0); let (kr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, "Keywords", t.semibold(13.5), t.text_label); + ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Keywords"), t.semibold(13.5), t.text_label); keyword_rows(app, ui, &tree, 0.0); } @@ -704,20 +711,20 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode } resp.context_menu(|ui| { let has_sel = app.session.active().is_some(); - if ui.add_enabled(has_sel, egui::Button::new("Add to Selected Photos")).clicked() { + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); } - if ui.add_enabled(has_sel, egui::Button::new("Remove from Selected Photos")).clicked() { + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); } ui.separator(); - if ui.button("Rename Keyword…").clicked() { + if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); } - if ui.button("Merge into…").clicked() { + if ui.button(crate::i18n::tr("Merge into…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); } - if ui.button("Delete Keyword").clicked() { + if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { let _ = app.run("keyword.delete", json!({"keyword": n.path})); } }); diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index a8a97148..3cdcc605 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -94,7 +94,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); let mut choice = None; let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); - egui::Window::new("Your library couldn't be opened") + egui::Window::new(crate::i18n::tr("Your library couldn't be opened")) .id(egui::Id::new("library-problem")) .order(egui::Order::Foreground) .collapsible(false) @@ -107,9 +107,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.set_width(440.0); ui.spacing_mut().item_spacing.y = 8.0; if problem.path.is_empty() { - ui.label(RichText::new("LightCraft couldn't find where to keep your library.").color(t.text)); + ui.label(RichText::new(crate::i18n::tr("LightCraft couldn't find where to keep your library.")).color(t.text)); } else { - ui.label(RichText::new("LightCraft couldn't open the library at").color(t.text_label)); + ui.label(RichText::new(crate::i18n::tr("LightCraft couldn't open the library at")).color(t.text_label)); ui.label(RichText::new(&problem.path).font(t.semibold(12.5)).color(t.text)); } ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap()); @@ -124,7 +124,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.add_space(4.0); ui.horizontal(|ui| { let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| { - let r = ui.add_enabled(enabled, egui::Button::new(label).min_size(vec2(0.0, 26.0))); + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0))); register(ui.ctx(), format!("button:{id}"), r.rect); if r.clicked() { choice = Some(c); @@ -166,12 +166,14 @@ pub fn banner(app: &mut LightcraftApp, ui: &mut egui::Ui) { .show(ui, |ui| { ui.horizontal_centered(|ui| { let r = ui.label( - RichText::new("Temporary session — your library isn't open, and nothing you do here is saved.") + RichText::new(crate::i18n::tr("Temporary session — your library isn't open, and nothing you do here is saved.")) .font(t.semibold(12.5)) .color(t.canvas), ); register(ui.ctx(), "indicator:temporarySession", r.rect); - let b = ui.add(egui::Button::new(RichText::new("Open Library…").color(t.canvas)).fill(egui::Color32::from_black_alpha(40))); + let b = ui.add( + egui::Button::new(RichText::new(crate::i18n::tr("Open Library…")).color(t.canvas)).fill(egui::Color32::from_black_alpha(40)), + ); register(ui.ctx(), "button:libraryReopen", b.rect); reopen = b.clicked(); }); diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index ab494432..f938dc8d 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -73,7 +73,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let d = app.session.develop_of(id).unwrap_or_default(); header(ui, "Masking"); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| { - ui.label(egui::RichText::new("Create New Mask").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Create New Mask")).color(t.text_dim)); ui.add_space(6.0); let tiles: [(&str, &str, Icon); 8] = [ ("subject", "Subject", Icon::Subject), @@ -123,7 +123,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // mask list egui::Frame::NONE.inner_margin(egui::Margin { left: 16, right: 16, top: 8, bottom: 8 }).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new("Masks").font(t.semibold(13.0)).color(t.text_label)); + ui.label(egui::RichText::new(crate::i18n::tr("Masks")).font(t.semibold(13.0)).color(t.text_label)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let on = app.ui.mask_overlay; if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() { @@ -132,7 +132,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); if d.masks.is_empty() { - ui.label(egui::RichText::new("No masks yet. Choose a mask type above.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("No masks yet. Choose a mask type above.")).color(t.text_dim)); } let count = d.masks.len(); for (index, m) in d.masks.iter().enumerate() { @@ -254,7 +254,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } resp.context_menu(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let more = ui.small_button("…").on_hover_text("Component options"); + let more = ui.small_button("…").on_hover_text(crate::i18n::tr("Component options")); register(ui.ctx(), format!("button:componentMenu{i}"), more.rect); egui::Popup::menu(&more).show(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len())); }); @@ -265,13 +265,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // wraps: the five actions are wider than a narrow panel ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.y = 6.0; - let add = text_button(ui, "maskAddComp", "Add", false); + let add = text_button(ui, "maskAddComp", crate::i18n::tr("Add"), false); egui::Popup::menu(&add).show(|ui| component_menu(app, ui, "add")); - let sub = text_button(ui, "maskSubComp", "Subtract", false); + let sub = text_button(ui, "maskSubComp", crate::i18n::tr("Subtract"), false); egui::Popup::menu(&sub).show(|ui| component_menu(app, ui, "subtract")); - let int = text_button(ui, "maskIntComp", "Intersect", false); + let int = text_button(ui, "maskIntComp", crate::i18n::tr("Intersect"), false); egui::Popup::menu(&int).show(|ui| component_menu(app, ui, "intersect")); - if text_button(ui, "maskInvert", "Invert", m.invert).clicked() { + if text_button(ui, "maskInvert", crate::i18n::tr("Invert"), m.invert).clicked() { let _ = app.run("mask.invert", json!({})); } if icon_button(ui, "maskDelete", Icon::Trash, vec2(26.0, 24.0), false, true, "Delete mask").clicked() { @@ -335,11 +335,11 @@ fn mask_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: u32, name: &str, vi run(ui, "Move Up", index > 0, "mask.move", json!({"id": id, "delta": -1})); run(ui, "Move Down", index + 1 < count, "mask.move", json!({"id": id, "delta": 1})); ui.separator(); - if ui.button("Rename…").clicked() { + if ui.button(crate::i18n::tr("Rename…")).clicked() { app.ui.renaming_mask = Some((id, name.to_string())); ui.close(); } - if ui.button("Delete Mask").clicked() { + if ui.button(crate::i18n::tr("Delete Mask")).clicked() { let _ = app.run("mask.delete", json!({"id": id})); ui.close(); } @@ -354,7 +354,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Overlay").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Overlay")).color(t.text_dim)); let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label); egui::Popup::menu(&r).show(|ui| { for v in MaskView::ALL { @@ -396,7 +396,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.ui.mask_overlay_opacity = v as f32; } let mut pins = app.ui.mask_pins; - if ui.checkbox(&mut pins, "Show Pins").changed() { + if ui.checkbox(&mut pins, crate::i18n::tr("Show Pins")).changed() { let _ = app.run("view.maskPins", json!({"show": pins})); } } @@ -410,7 +410,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape }; match shape { MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => { - ui.label(egui::RichText::new("Select Luminance Range").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(crate::i18n::tr("Select Luminance Range")).color(t.text_dim).size(11.5)); let (lo, hi) = (*lo, *hi); let w = ui.available_width().min(240.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::click_and_drag()); @@ -471,7 +471,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape app.run("mask.update", json!({"component": comp, "shape": MaskShape::LuminanceRange { lo, hi, lo_feather: f, hi_feather: f }})) }); let mut map = app.ui.mask_overlay && app.ui.mask_overlay_mode == "colorOnBw"; - let r = ui.checkbox(&mut map, "Show Luminance Map"); + let r = ui.checkbox(&mut map, crate::i18n::tr("Show Luminance Map")); register(ui.ctx(), format!("check:lumMap{comp}"), r.rect); if r.changed() { // the luminance map: the photo in black & white with the selected range tinted @@ -494,8 +494,8 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape .size(11.5), ); let picking = app.ui.tool == "colorRange"; - if text_button(ui, &format!("colorPick{comp}"), "Pick", picking) - .on_hover_text("Click the photo to pick a colour; ⇧-click adds more (up to 5)") + if text_button(ui, &format!("colorPick{comp}"), crate::i18n::tr("Pick"), picking) + .on_hover_text(crate::i18n::tr("Click the photo to pick a colour; ⇧-click adds more (up to 5)")) .clicked() { app.ui.tool = if picking { String::new() } else { "colorRange".into() }; @@ -534,22 +534,26 @@ fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k: } }; run(ui, "Invert", json!({"action": "invert"})); - run(ui, &format!("Duplicate \"{label}\""), json!({"action": "duplicate"})); + run(ui, &crate::i18n::tr_format!("Duplicate \"{label}\"", label = label), json!({"action": "duplicate"})); if k > 0 { - ui.menu_button("Mode", |ui| { + ui.menu_button(crate::i18n::tr("Mode"), |ui| { for (op, text) in [("add", "Add"), ("subtract", "Subtract"), ("intersect", "Intersect")] { run(ui, text, json!({"action": "op", "op": op})); } }); } - ui.menu_button("Intersect with", |ui| component_menu(app, ui, "intersect")); - ui.menu_button("Subtract", |ui| component_menu(app, ui, "subtract")); + ui.menu_button(crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect")); + ui.menu_button(crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract")); ui.separator(); - if ui.button("Rename…").clicked() { + if ui.button(crate::i18n::tr("Rename…")).clicked() { app.ui.renaming_component = Some((mask, k, label.to_string())); ui.close(); } - let del = if count == 1 { format!("Delete \"{label}\" (and the mask)") } else { format!("Delete \"{label}\"") }; + let del = if count == 1 { + crate::i18n::tr_format!("Delete \"{label}\" (and the mask)", label = label) + } else { + crate::i18n::tr_format!("Delete \"{label}\"", label = label) + }; if ui.button(del).clicked() { let _ = app.run("mask.component", json!({"id": mask, "component": k, "action": "delete"})); ui.close(); @@ -584,16 +588,20 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 0 }).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new("Brush").font(t.semibold(13.0))); - if text_button(ui, "brushAdd", "Add", !app.ui.brush_erase).clicked() { + ui.label(egui::RichText::new(crate::i18n::tr("Brush")).font(t.semibold(13.0))); + if text_button(ui, "brushAdd", crate::i18n::tr("Add"), !app.ui.brush_erase).clicked() { app.ui.brush_erase = false; } - if text_button(ui, "brushErase", "Erase", app.ui.brush_erase).clicked() { + if text_button(ui, "brushErase", crate::i18n::tr("Erase"), app.ui.brush_erase).clicked() { app.ui.brush_erase = true; } }); let mut auto = app.ui.brush_auto_mask; - if ui.checkbox(&mut auto, "Auto Mask").on_hover_text("Paint only areas like the one under the brush").changed() { + if ui + .checkbox(&mut auto, crate::i18n::tr("Auto Mask")) + .on_hover_text(crate::i18n::tr("Paint only areas like the one under the brush")) + .changed() + { app.ui.brush_auto_mask = auto; } }); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7103c42a..cfb40d68 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -85,6 +85,6 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) { let t = Tokens::get(ui.ctx()); let p = ui.painter(); - p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, title, t.semibold(18.0), t.text_label); - p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, body, t.font(13.0), t.text_dim); + p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, crate::i18n::tr(title), t.semibold(18.0), t.text_label); + p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, crate::i18n::tr(body), t.font(13.0), t.text_dim); } diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index e8615a36..74b1e129 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -26,7 +26,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, "Presets", t.semibold(15.0), t.text); + ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr("Presets"), t.semibold(15.0), t.text); let mut hdr = ui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom())) @@ -38,14 +38,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options"); egui::Popup::menu(&more).show(|ui| { - let r = ui.checkbox(&mut app.ui.preset_thumbs, "Show Thumbnails"); + let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails")); register(ui.ctx(), "presetMenu:thumbnails", r.rect); ui.separator(); - if ui.button("Import Presets…").clicked() { + if ui.button(crate::i18n::tr("Import Presets…")).clicked() { let _ = app.run("file.importPresets", json!({})); } let any_user = app.session.presets.iter().any(|p| !p.builtin); - if ui.add_enabled(any_user, egui::Button::new("Export User Presets…")).clicked() { + if ui.add_enabled(any_user, egui::Button::new(crate::i18n::tr("Export User Presets…"))).clicked() { let _ = app.run("file.exportPresets", json!({})); } }); @@ -99,7 +99,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(open_id, !open)); } resp.context_menu(|ui| { - if ui.button("Export Group…").clicked() { + if ui.button(crate::i18n::tr("Export Group…")).clicked() { let _ = app.run("file.exportPresets", json!({"group": g})); } }); @@ -141,12 +141,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.hovered() && let Some(s) = look { - app.hover_preview = Some(HoverPreview { label: format!("Preset: {name}"), settings: s }); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Preset: {name}", name = name), settings: s }); } if resp.clicked() { let _ = app.run("preset.apply", json!({"id": pid, "amount": 100})); ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0))); - app.toast(ui.ctx(), format!("Preset: {name}")); + app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); } let (builtin, group) = (pr.builtin, pr.group.clone()); resp.context_menu(|ui| { @@ -157,13 +157,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; } ui.separator(); - if ui.add_enabled(app.session.active().is_some(), egui::Button::new("Update with Current Settings")).clicked() { + if ui + .add_enabled(app.session.active().is_some(), egui::Button::new(crate::i18n::tr("Update with Current Settings"))) + .clicked() + { match app.run("preset.update", json!({"id": pid})) { Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")), Err(e) => app.toast(ui.ctx(), e), } } - if ui.button("Rename…").clicked() { + if ui.button(crate::i18n::tr("Rename…")).clicked() { crate::panels::dialogs::prompt( app, "Rename Preset", @@ -174,7 +177,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { "name", ); } - ui.menu_button("Move to Group", |ui| { + ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { let mut groups: Vec = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect(); groups.sort(); groups.dedup(); @@ -183,7 +186,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("preset.move", json!({"id": pid, "group": g})); } } - if ui.button("New Group…").clicked() { + if ui.button(crate::i18n::tr("New Group…")).clicked() { crate::panels::dialogs::prompt( app, "Move Preset to New Group", @@ -196,7 +199,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); ui.separator(); - if ui.button("Delete Preset").clicked() { + if ui.button(crate::i18n::tr("Delete Preset")).clicked() { let _ = app.run("preset.delete", json!({"id": pid})); } }); diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 9e32c52a..ff54f446 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -29,12 +29,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if icon_button(&mut back, "profilesBack", Icon::ChevronLeft, vec2(28.0, 28.0), false, true, "Back to Edit").clicked() { app.ui.right = RightPanel::Edit; } - ui.painter().text(pos2(hr.left() + 46.0, hr.center().y + 2.0), Align2::LEFT_CENTER, "Profiles", t.semibold(15.0), t.text); + ui.painter().text(pos2(hr.left() + 46.0, hr.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr("Profiles"), t.semibold(15.0), t.text); divider(ui); // the applied profile and its amount 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, "Profile", t.font(13.0), t.text_dim); + 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); if d.profile.id != "lc.color" { super::edit::control(app, ui, &d, "profile.amount", true); @@ -126,7 +126,7 @@ 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: format!("Profile: {}", p.name), settings: s.clone() }); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s.clone() }); } let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group)); if resp.clicked() { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index a4b27e32..1a07b5c6 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -54,7 +54,7 @@ pub fn header(ui: &mut egui::Ui, title: &str) { pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); - ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, label, t.font(12.5), t.text_dim); + ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(label), t.font(12.5), t.text_dim); ui.painter().text(pos2(r.left() + 110.0, r.center().y), Align2::LEFT_CENTER, value, t.font(12.5), t.text_label); } @@ -67,7 +67,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { header(ui, "Crop"); padded(ui, |ui| { ui.horizontal(|ui| { - ui.label("Aspect Ratio"); + 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)); register(ui.ctx(), "button:cropAspect", r.rect); @@ -92,25 +92,25 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.add_space(6.0); ui.horizontal_wrapped(|ui| { - if text_button(ui, "cropRotateLeft", "Rotate Left", false).clicked() { + if text_button(ui, "cropRotateLeft", crate::i18n::tr("Rotate Left"), false).clicked() { let _ = app.run("photo.rotateLeft", json!({})); } - if text_button(ui, "cropRotateRight", "Rotate Right", false).clicked() { + if text_button(ui, "cropRotateRight", crate::i18n::tr("Rotate Right"), false).clicked() { let _ = app.run("photo.rotateRight", json!({})); } }); ui.add_space(6.0); ui.horizontal_wrapped(|ui| { - if text_button(ui, "cropFlipH", "Flip H", d.crop.flip_h).clicked() { + if text_button(ui, "cropFlipH", crate::i18n::tr("Flip H"), d.crop.flip_h).clicked() { let _ = app.run("photo.flipHorizontal", json!({})); } - if text_button(ui, "cropFlipV", "Flip V", d.crop.flip_v).clicked() { + if text_button(ui, "cropFlipV", crate::i18n::tr("Flip V"), d.crop.flip_v).clicked() { let _ = app.run("photo.flipVertical", json!({})); } - if text_button(ui, "cropSwapAspect", "Swap Aspect (X)", false).clicked() { + if text_button(ui, "cropSwapAspect", crate::i18n::tr("Swap Aspect (X)"), false).clicked() { let _ = app.run("crop.rotateAspect", json!({})); } - if text_button(ui, "cropReset", "Reset", false).clicked() { + if text_button(ui, "cropReset", crate::i18n::tr("Reset"), false).clicked() { let _ = app.run("crop.reset", json!({})); } }); @@ -123,16 +123,19 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { padded(ui, |ui| { ui.horizontal(|ui| { let on = app.ui.tool == "straighten"; - if text_button(ui, "straightenTool", "Straighten Tool", on).on_hover_text("Drag along the horizon; double-click for Auto").clicked() { + if text_button(ui, "straightenTool", crate::i18n::tr("Straighten Tool"), on) + .on_hover_text(crate::i18n::tr("Drag along the horizon; double-click for Auto")) + .clicked() + { app.ui.tool = if on { String::new() } else { "straighten".into() }; } - if text_button(ui, "straightenAuto", "Auto", false).clicked() { + if text_button(ui, "straightenAuto", crate::i18n::tr("Auto"), false).clicked() { let _ = app.run("crop.autoStraighten", json!({})); } }); }); padded(ui, |ui| { - ui.label("Overlay"); + ui.label(crate::i18n::tr("Overlay")); { use crate::state::CropOverlay as O; let opts = [ @@ -149,7 +152,8 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if let Some(i) = crate::widgets::segmented(ui, "cropOverlay", &items, active, 4) { app.ui.crop_overlay = opts[i].0; } - if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral) && text_button(ui, "cropOverlayOrient", "Flip Overlay (⇧O)", false).clicked() + if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral) + && text_button(ui, "cropOverlayOrient", crate::i18n::tr("Flip Overlay (⇧O)"), false).clicked() { let _ = app.run("view.cropOverlayOrientation", json!({})); } @@ -158,7 +162,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { divider(ui); header(ui, "Geometry"); padded(ui, |ui| { - ui.label("Upright"); + ui.label(crate::i18n::tr("Upright")); { use lightcraft_develop::Upright; let modes = [ @@ -179,30 +183,36 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } let guided = d.geometry.upright == lightcraft_develop::Upright::Guided; ui.horizontal_wrapped(|ui| { - if !guided && d.geometry.upright != lightcraft_develop::Upright::Off && text_button(ui, "uprightUpdate", "Update", false).clicked() { + if !guided + && d.geometry.upright != lightcraft_develop::Upright::Off + && text_button(ui, "uprightUpdate", crate::i18n::tr("Update"), false).clicked() + { let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default(); let _ = app.run("geometry.upright", json!({"mode": mode})); } if guided { let drawing = app.ui.tool == "guidedUpright"; - if text_button(ui, "uprightDraw", "Draw Guides", drawing).clicked() { + if text_button(ui, "uprightDraw", crate::i18n::tr("Draw Guides"), drawing).clicked() { app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() }; } - if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", "Clear Guides", false).clicked() { + if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", crate::i18n::tr("Clear Guides"), false).clicked() { let _ = app.run("geometry.guides", json!({"guides": []})); } } }); if guided { ui.label( - egui::RichText::new(format!("{} of 4 guides — drag along lines that should be vertical or horizontal.", d.geometry.guides.len())) - .size(11.0) - .color(Tokens::get(ui.ctx()).text_dim), + egui::RichText::new(crate::i18n::tr_format!( + "{} of 4 guides — drag along lines that should be vertical or horizontal.", + d.geometry.guides.len() + )) + .size(11.0) + .color(Tokens::get(ui.ctx()).text_dim), ); } ui.add_space(4.0); let mut c = d.geometry.constrain_crop; - if ui.checkbox(&mut c, "Constrain Crop").changed() { + if ui.checkbox(&mut c, crate::i18n::tr("Constrain Crop")).changed() { let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"})); } }); @@ -225,10 +235,10 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.add_space(8.0); - ui.label(format!("{} spot(s) on this photo", d.spots.len())); + ui.label(crate::i18n::tr_format!("{} spot(s) on this photo", d.spots.len())); ui.add_space(4.0); - if text_button(ui, "findDust", "Find Dust Spots", false) - .on_hover_text("Heal the small, soft dark spots sensor dust leaves on smooth areas") + if text_button(ui, "findDust", crate::i18n::tr("Find Dust Spots"), false) + .on_hover_text(crate::i18n::tr("Heal the small, soft dark spots sensor dust leaves on smooth areas")) .clicked() { match app.run("spot.findDust", json!({})) { @@ -236,14 +246,18 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let n = r["added"].as_u64().unwrap_or(0); app.toast( ui.ctx(), - if n == 0 { "No dust spots found".to_string() } else { format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }) }, + if n == 0 { + "No dust spots found".to_string() + } else { + crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n) + }, ); } Err(e) => app.toast(ui.ctx(), e), } } ui.add_space(4.0); - ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Paint over a distraction on the photo to remove it.")).color(Tokens::get(ui.ctx()).text_dim)); }); // brush settings; with a spot selected they edit that spot too let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone()))); @@ -255,7 +269,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { lightcraft_develop::SpotMode::Clone => "Clone", lightcraft_develop::SpotMode::Remove => "Remove", }; - super::edit::sub_title(ui, &format!("{mode} spot {} of {}", i + 1, d.spots.len())); + super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = mode)); } let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { id, @@ -290,10 +304,10 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if sel.is_some() { padded(ui, |ui| { ui.horizontal(|ui| { - if text_button(ui, "spotRefresh", "Refresh Source (/)", false).clicked() { + if text_button(ui, "spotRefresh", crate::i18n::tr("Refresh Source (/)"), false).clicked() { let _ = app.run("spot.refreshSource", json!({})); } - if text_button(ui, "spotDelete", "Delete (⌫)", false).clicked() { + if text_button(ui, "spotDelete", crate::i18n::tr("Delete (⌫)"), false).clicked() { let _ = app.run("spot.delete", json!({})); } }); @@ -302,7 +316,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out padded(ui, |ui| { let mut v = app.ui.visualize_spots; - if ui.checkbox(&mut v, "Visualize Spots (A)").changed() { + if ui.checkbox(&mut v, crate::i18n::tr("Visualize Spots (A)")).changed() { app.ui.visualize_spots = v; } }); @@ -322,7 +336,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.spots_threshold = v as f32; } padded(ui, |ui| { - if !d.spots.is_empty() && text_button(ui, "removeClear", "Delete all spots", false).clicked() { + if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() { for i in (0..d.spots.len()).rev() { let _ = app.run("spot.delete", json!({"index": i})); } @@ -343,7 +357,8 @@ fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.add_space(8.0); ui.label( - egui::RichText::new("Drag over an eye on the photo; the pupil inside is found automatically.").color(Tokens::get(ui.ctx()).text_dim), + egui::RichText::new(crate::i18n::tr("Drag over an eye on the photo; the pupil inside is found automatically.")) + .color(Tokens::get(ui.ctx()).text_dim), ); }); let n = d.red_eye.len(); @@ -367,16 +382,16 @@ fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { padded(ui, |ui| { if eye.pet { let mut on = eye.catchlight.is_some(); - if ui.checkbox(&mut on, "Add Catchlight").changed() { + if ui.checkbox(&mut on, crate::i18n::tr("Add Catchlight")).changed() { let _ = app.run("redeye.catchlight", json!({"index": i, "on": on})); } ui.add_space(4.0); } ui.horizontal(|ui| { - if text_button(ui, "eyeDelete", "Delete", false).clicked() { + if text_button(ui, "eyeDelete", crate::i18n::tr("Delete"), false).clicked() { let _ = app.run("redeye.delete", json!({"index": i})); } - if n > 1 && text_button(ui, "eyeDeleteAll", "Delete all", false).clicked() { + if n > 1 && text_button(ui, "eyeDeleteAll", crate::i18n::tr("Delete all"), false).clicked() { for k in (0..n).rev() { let _ = app.run("redeye.delete", json!({"index": k})); } @@ -515,7 +530,10 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }); ui.horizontal(|ui| { ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim)); - if text_button(ui, "showOnMap", "Show on Map", false).on_hover_text("Open the place in OpenStreetMap").clicked() { + if text_button(ui, "showOnMap", crate::i18n::tr("Show on Map"), false) + .on_hover_text(crate::i18n::tr("Open the place in OpenStreetMap")) + .clicked() + { let url = format!("https://www.openstreetmap.org/?mlat={la:.6}&mlon={lo:.6}#map=15/{la:.6}/{lo:.6}"); if let Err(e) = crate::links::open(app, &url) { app.toast(ui.ctx(), e); @@ -556,7 +574,10 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } ui.add_space(10.0); - if text_button(ui, "allMetadata", "All Metadata…", false).on_hover_text("Every EXIF, GPS and XMP field in the file").clicked() { + if text_button(ui, "allMetadata", crate::i18n::tr("All Metadata…"), false) + .on_hover_text(crate::i18n::tr("Every EXIF, GPS and XMP field in the file")) + .clicked() + { let _ = app.run("dialog.allMetadata", json!({})); } }); @@ -616,10 +637,10 @@ fn human_size(bytes: u64) -> String { /// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new("Copyright Status").size(11.5).color(t.text_dim)); - let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(current.label()).show_ui(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); + let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { for st in lightcraft_catalog::CopyrightStatus::ALL { - if ui.selectable_label(st == current, st.label()).clicked() && st != current { + if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current { let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); } } @@ -654,7 +675,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { padded(ui, |ui| { let kid = egui::Id::new("kw-input"); let mut text = ui.data_mut(|d| d.get_temp::(kid).unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text("Add keyword").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("Add keyword")).desired_width(f32::INFINITY)); register(ui.ctx(), "field:keyword", r.rect); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !text.trim().is_empty() { let kws: Vec = text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); @@ -672,17 +693,17 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]})); } r.context_menu(|ui| { - if ui.button("Remove from Photo").clicked() { + if ui.button(crate::i18n::tr("Remove from Photo")).clicked() { let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]})); } - if ui.button("Show Photos with Keyword").clicked() { + if ui.button(crate::i18n::tr("Show Photos with Keyword")).clicked() { let _ = app.run("library.filter", json!({"keyword": k})); } ui.separator(); - if ui.button("Rename Keyword…").clicked() { + if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() }); } - if ui.button("Delete Keyword").clicked() { + if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { let _ = app.run("keyword.delete", json!({"keyword": k})); } }); @@ -696,12 +717,15 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let pid = egui::Id::new("kw-painter"); let painting = app.ui.keyword_painter.clone(); let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default()); - let r = ui.add_enabled(painting.is_none(), egui::TextEdit::singleline(&mut k).hint_text("Keyword to paint").desired_width(130.0)); + let r = ui.add_enabled( + painting.is_none(), + egui::TextEdit::singleline(&mut k).hint_text(crate::i18n::tr("Keyword to paint")).desired_width(130.0), + ); register(ui.ctx(), "field:keywordPainter", r.rect); ui.data_mut(|d| d.insert_temp(pid, k.clone())); let label = if painting.is_some() { "Stop" } else { "Paint" }; if text_button(ui, "keywordPaint", label, painting.is_some()) - .on_hover_text("Click photos in the grid to toggle the keyword; Esc stops") + .on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops")) .clicked() { let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }})); @@ -714,7 +738,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let last = typed.rsplit(',').next().unwrap_or("").trim().to_string(); let suggestions = (*app.caches.suggestions(&app.session.catalog, &p.meta.keywords, &last, 12)).clone(); if !suggestions.is_empty() { - ui.label(egui::RichText::new("Suggestions").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); ui.horizontal_wrapped(|ui| { for k in suggestions { let r = ui @@ -739,7 +763,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) { let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); let current = sets["current"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Keyword Set").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); egui::ComboBox::from_id_salt("kw-set").selected_text(¤t).show_ui(ui, |ui| { for set in sets["sets"].as_array().into_iter().flatten() { let name = set["name"].as_str().unwrap_or_default(); @@ -748,7 +772,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) { } } ui.separator(); - if ui.button("Save Current Keywords as Set…").clicked() { + if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::TextPrompt { title: "Save Keyword Set".into(), hint: "Set name".into(), @@ -758,14 +782,16 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) { key: "name".into(), }); } - if current != lightcraft_engine::cmd::keywords::RECENT && ui.button(format!("Delete “{current}”")).clicked() { + if current != lightcraft_engine::cmd::keywords::RECENT + && ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked() + { let _ = app.run("keyword.deleteSet", json!({"name": current})); } }); }); let kws: Vec = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); if kws.is_empty() { - ui.label(egui::RichText::new("Keywords you add appear here; ⌥1–⌥9 apply them.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Keywords you add appear here; ⌥1–⌥9 apply them.")).color(t.text_dim)); return; } let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0); @@ -795,7 +821,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { header(ui, "Versions"); let t = Tokens::get(ui.ctx()); padded(ui, |ui| { - if text_button(ui, "versionCreate", "Create Version", false).clicked() { + if text_button(ui, "versionCreate", crate::i18n::tr("Create Version"), false).clicked() { let _ = app.run("version.create", json!({})); } ui.add_space(8.0); @@ -804,7 +830,7 @@ 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 = [format!("Named ({named_n})"), format!("Auto ({})", p.versions.len() - named_n)]; + let labels = [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), 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; @@ -847,7 +873,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } // resting on a version shows it in the loupe (nothing is changed) if resp.hovered() && !current { - app.hover_preview = Some(crate::HoverPreview { label: format!("Version: {}", v.name), settings: (*v.settings).clone() }); + app.hover_preview = + Some(crate::HoverPreview { label: crate::i18n::tr_format!("Version: {}", v.name), settings: (*v.settings).clone() }); } if resp.double_clicked() { crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); @@ -856,20 +883,20 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } resp.on_hover_text(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }).context_menu( |ui| { - if ui.button("Restore").clicked() { + if ui.button(crate::i18n::tr("Restore")).clicked() { let _ = app.run("version.restore", json!({"index": i})); } - if ui.button("Update with Current Settings").clicked() { + if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() { let _ = app.run("version.update", json!({"index": i})); } - if ui.button("Rename…").clicked() { + if ui.button(crate::i18n::tr("Rename…")).clicked() { crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); } - if ui.button("Set as Before").clicked() { + if ui.button(crate::i18n::tr("Set as Before")).clicked() { let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name})); } ui.separator(); - if ui.button("Delete").clicked() { + if ui.button(crate::i18n::tr("Delete")).clicked() { let _ = app.run("version.delete", json!({"index": i})); } }, @@ -899,7 +926,7 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); padded(ui, |ui| { if p.history.is_empty() { - ui.label(egui::RichText::new("No edits yet.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim)); } for (i, h) in p.history.iter().enumerate().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); @@ -913,15 +940,15 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("history.restore", json!({"index": i})); } resp.context_menu(|ui| { - if ui.button("Copy History Step Settings to Before").clicked() { + if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() { let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i})); } - if ui.button("Create Version from Step").clicked() { + if ui.button(crate::i18n::tr("Create Version from Step")).clicked() { let _ = app.run("history.restore", json!({"index": i})); let _ = app.run("version.create", json!({"name": h.label})); } ui.separator(); - if ui.button("Clear History").clicked() { + if ui.button(crate::i18n::tr("Clear History")).clicked() { let _ = app.run("history.clear", json!({})); } }); diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index bfe3f4d0..ecb12636 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -98,11 +98,11 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & let Value::Array(a) = v else { return }; if k == Kind::Number { number_value(ui, &mut a[0], field); - ui.label("and"); + ui.label(crate::i18n::tr("and")); number_value(ui, &mut a[1], field); } else { text_value(ui, &mut a[0], 86.0, "2026-01-01", &format!("{salt}-a")); - ui.label("and"); + ui.label(crate::i18n::tr("and")); text_value(ui, &mut a[1], 86.0, "2026-12", &format!("{salt}-b")); } } @@ -134,9 +134,9 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { - ui.label(RichText::new("Match").color(t.text_label)); + ui.label(RichText::new(crate::i18n::tr("Match")).color(t.text_label)); match_combo(ui, salt, &mut rs.mode); - ui.label(RichText::new("of the following rules:").color(t.text_label)); + ui.label(RichText::new(crate::i18n::tr("of the following rules:")).color(t.text_label)); }); let mut remove = None; let mut insert: Option<(usize, Rule)> = None; @@ -146,7 +146,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { Rule::Group { group } => { egui::Frame::group(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { - if ui.small_button("−").on_hover_text("Remove this group").clicked() { + if ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this group")).clicked() { remove = Some(i); } ui.vertical(|ui| edit(ui, group, &rsalt, depth + 1)); @@ -182,7 +182,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { }); value_editor(ui, field, op, value, &rsalt); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let add = ui.small_button("+").on_hover_text("Add a rule (⌥-click: a group)"); + let add = ui.small_button("+").on_hover_text(crate::i18n::tr("Add a rule (⌥-click: a group)")); register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect); if add.clicked() { let group = ui.input(|i| i.modifiers.alt) && depth < 3; @@ -191,7 +191,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { if group { Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } } } else { new_rule() }, )); } - let del = ui.small_button("−").on_hover_text("Remove this rule"); + let del = ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this rule")); register(ui.ctx(), format!("button:ruleRemove-{rsalt}"), del.rect); if del.clicked() { remove = Some(i); diff --git a/crates/ui-egui/src/panels/second.rs b/crates/ui-egui/src/panels/second.rs index 1a07bfb0..775a922d 100644 --- a/crates/ui-egui/src/panels/second.rs +++ b/crates/ui-egui/src/panels/second.rs @@ -26,7 +26,13 @@ fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) { crate::widgets::register(ui.ctx(), "view:secondWindow", area); ui.allocate_rect(area, egui::Sense::hover()); let Some(id) = app.session.active() else { - ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, "No photo selected", egui::FontId::proportional(14.0), Color32::GRAY); + ui.painter().text( + area.center(), + egui::Align2::CENTER_CENTER, + crate::i18n::tr("No photo selected"), + egui::FontId::proportional(14.0), + Color32::GRAY, + ); return; }; // a render sized for this window diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ee917017..a380090e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -49,14 +49,14 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { ui.add_space(4.0); - ui.label(RichText::new(text).font(t.semibold(12.5)).color(t.text)); + ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); } fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { ui.horizontal(|ui| { ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(LABEL_W); - ui.label(RichText::new(label).color(t.text_label)); + ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label)); }); add(ui) }) @@ -64,12 +64,12 @@ fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui } fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { - ui.label(RichText::new(text).size(11.0).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim)); } /// A checkbox addressable as `check:{id}`; true when toggled. fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { - let r = ui.checkbox(value, label); + let r = ui.checkbox(value, crate::i18n::tr(label)); register(ui.ctx(), format!("check:{id}"), r.rect); r.changed() } @@ -90,27 +90,31 @@ fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str // ------------------------------------------------------------------------------------- General fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - heading(ui, t, "Library"); + row(ui, t, crate::i18n::tr("Language"), |ui| { + choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language); + crate::i18n::set_language(app.ui.language); + }); + heading(ui, t, crate::i18n::tr("Library")); let location = match &app.session.library { Some(l) => l.dir.display().to_string(), - None => "In memory — nothing is saved".to_string(), + None => crate::i18n::tr("In memory — nothing is saved").to_string(), }; - row(ui, t, "Location", |ui| { + row(ui, t, crate::i18n::tr("Location"), |ui| { ui.label(RichText::new(location).color(t.text)); }); row(ui, t, "", |ui| { let can = app.services.pick_folder.is_some(); - let r = ui.add_enabled(can, egui::Button::new("Open Library…")); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Open Library…"))); register(ui.ctx(), "button:settingsOpenLibrary", r.rect); if r.clicked() { let _ = app.run("app.openLibrary", json!({})); } if !can { - hint(ui, t, "not available here"); + hint(ui, t, crate::i18n::tr("not available here")); } }); - heading(ui, t, "Startup"); - row(ui, t, "Open in", |ui| { + heading(ui, t, crate::i18n::tr("Startup")); + row(ui, t, crate::i18n::tr("Open in"), |ui| { choices( ui, "settingsStartup", @@ -118,18 +122,21 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { &mut app.ui.settings.startup_view, ); }); - heading(ui, t, "Culling"); + heading(ui, t, crate::i18n::tr("Culling")); check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); - heading(ui, t, "External Editor"); - row(ui, t, "Application", |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text("System default").desired_width(220.0)); + heading(ui, t, crate::i18n::tr("External Editor")); + row(ui, t, crate::i18n::tr("Application"), |ui| { + let r = ui + .add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text(crate::i18n::tr("System default")).desired_width(220.0)); register(ui.ctx(), "field:externalEditor", r.rect); }); hint( ui, t, - "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).", + crate::i18n::tr( + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).", + ), ); } @@ -176,9 +183,9 @@ fn cameras(app: &LightcraftApp) -> Vec { fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let d = app.session.import_defaults.clone(); - heading(ui, t, "Raw defaults"); - hint(ui, t, "Settings new raw photos start from. Changing them doesn't touch photos already in the library."); - row(ui, t, "Raw photos", |ui| { + heading(ui, t, crate::i18n::tr("Raw defaults")); + hint(ui, t, crate::i18n::tr("Settings new raw photos start from. Changing them doesn't touch photos already in the library.")); + row(ui, t, crate::i18n::tr("Raw photos"), |ui| { if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") { let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}})); } @@ -192,7 +199,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if per { let cams = cameras(app); if cams.is_empty() { - hint(ui, t, "No raw photos yet: cameras appear here once their photos are in the library."); + hint(ui, t, crate::i18n::tr("No raw photos yet: cameras appear here once their photos are in the library.")); } egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| { for (i, cam) in cams.iter().enumerate() { @@ -212,10 +219,10 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }; let id = format!("settingsCamera-{i}"); let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { - if ui.selectable_label(current == Some(RAW_DEFAULT), "Same as raw default").clicked() { + if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() { pick = Some(json!({"camera": cam, "remove": true})); } - if ui.selectable_label(current.is_none(), "LightCraft Default").clicked() { + if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() { pick = Some(json!({"camera": cam, "preset": null})); } for p in &app.session.presets { @@ -232,14 +239,14 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); } - heading(ui, t, "Other images (JPEG, PNG, TIFF, HEIC…)"); - row(ui, t, "Non-raw photos", |ui| { + heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)")); + row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| { if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") { let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}})); } }); - heading(ui, t, "Metadata"); - hint(ui, t, "Added to photos you import that don't already have it."); + heading(ui, t, crate::i18n::tr("Metadata")); + hint(ui, t, crate::i18n::tr("Added to photos you import that don't already have it.")); for (key, label, hint_text, value) in [("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())] { @@ -259,14 +266,14 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } if !app.session.metadata_presets.is_empty() { - row(ui, t, "Metadata preset", |ui| { + row(ui, t, crate::i18n::tr("Metadata preset"), |ui| { let cur = d.metadata_preset.clone(); let mut pick = None; let r = egui::ComboBox::from_id_salt("settingsMetaPreset") .width(240.0) .selected_text(cur.clone().unwrap_or_else(|| "None".into())) .show_ui(ui, |ui| { - if ui.selectable_label(cur.is_none(), "None").clicked() { + if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() { pick = Some(String::new()); } for m in &app.session.metadata_presets { @@ -281,12 +288,12 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); } - heading(ui, t, "XMP sidecars"); + heading(ui, t, crate::i18n::tr("XMP sidecars")); let mut xmp = app.session.xmp; if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write})); } - row(ui, t, "Sidecar names", |ui| { + row(ui, t, crate::i18n::tr("Sidecar names"), |ui| { use lightcraft_engine::sidecar::SidecarNaming as N; let mut n = xmp.naming; if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) { @@ -295,12 +302,12 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); if !cfg!(target_arch = "wasm32") { - heading(ui, t, "Auto Import"); - hint(ui, t, "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete."); - row(ui, t, "Watched folder", |ui| { + heading(ui, t, crate::i18n::tr("Auto Import")); + hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.")); + row(ui, t, crate::i18n::tr("Watched folder"), |ui| { ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| "Off".into())).color(t.text)); let can = app.services.pick_folder.is_some(); - let r = ui.add_enabled(can, egui::Button::new("Choose…")); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…"))); register(ui.ctx(), "button:settingsAutoFolder", r.rect); if r.clicked() && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) @@ -308,21 +315,21 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { { app.toast(ui.ctx(), e); } - if d.auto_folder.is_some() && ui.button("Turn Off").clicked() { + if d.auto_folder.is_some() && ui.button(crate::i18n::tr("Turn Off")).clicked() { let _ = app.run("library.autoImport", json!({"folder": null})); } }); if d.auto_folder.is_some() { row(ui, t, "", |ui| { let mut copy = d.auto_copy; - if ui.checkbox(&mut copy, "Copy into the library (else use the files where they are)").changed() { + if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() { let _ = app.run("library.autoImport", json!({"copy": copy})); } }); - row(ui, t, "Album", |ui| { + row(ui, t, crate::i18n::tr("Album"), |ui| { let id = egui::Id::new("auto-album"); let mut name = ui.data(|m| m.get_temp::(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text("None").desired_width(180.0)); + let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0)); if r.lost_focus() { let _ = app.run("library.autoImport", json!({"album": name.trim()})); } @@ -331,7 +338,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } } if app.session.library.is_none() { - hint(ui, t, "In-memory session: these settings last until LightCraft quits."); + hint(ui, t, crate::i18n::tr("In-memory session: these settings last until LightCraft quits.")); } } @@ -339,31 +346,31 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use lightcraft_engine::gpu; - heading(ui, t, "Rendering"); + heading(ui, t, crate::i18n::tr("Rendering")); check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); let status = if !gpu::available() { match gpu::unavailable_reason() { - Some(why) => format!("Rendering on the CPU: {why}"), + Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why), None => "No usable GPU found: rendering on the CPU".to_string(), } } else { format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) }; hint(ui, t, &status); - row(ui, t, "Preview size", |ui| { + row(ui, t, crate::i18n::tr("Preview size"), |ui| { let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect(); let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect(); choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge); }); - hint(ui, t, "Largest long edge the Detail view renders at; larger is sharper on big displays but slower."); - row(ui, t, "Memory for caches", |ui| { - let auto = format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); + hint(ui, t, crate::i18n::tr("Largest long edge the Detail view renders at; larger is sharper on big displays but slower.")); + row(ui, t, crate::i18n::tr("Memory for caches"), |ui| { + let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")]; choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb); }); - heading(ui, t, "Thumbnail cache"); + heading(ui, t, crate::i18n::tr("Thumbnail cache")); let cur = (app.session.cache_bytes() >> 20) as u32; - row(ui, t, "Size limit", |ui| { + row(ui, t, crate::i18n::tr("Size limit"), |ui| { let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")]; let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 }; if choices(ui, "settingsCache", &opts, &mut v) { @@ -371,16 +378,16 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); let used = app.session.media.rendered.disk().map(|d| d.size()); - row(ui, t, "In use", |ui| { + row(ui, t, crate::i18n::tr("In use"), |ui| { ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text)); - let r = ui.button("Clear Cache"); + let r = ui.button(crate::i18n::tr("Clear Cache")); register(ui.ctx(), "button:settingsClearCache", r.rect); if r.clicked() { let _ = app.run("library.clearPreviews", json!({})); } }); - heading(ui, t, "Local folders"); - row(ui, t, "Forget unchanged photos", |ui| { + heading(ui, t, crate::i18n::tr("Local folders")); + row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| { let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")]; let mut v = app.session.forget_local_days; if choices(ui, "settingsForgetLocal", &opts, &mut v) { @@ -390,7 +397,9 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { hint( ui, t, - "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.", + crate::i18n::tr( + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.", + ), ); #[cfg(not(target_arch = "wasm32"))] smart_previews(app, ui, t); @@ -414,29 +423,40 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if loc.is_null() { return; } - heading(ui, t, "Smart previews"); + heading(ui, t, crate::i18n::tr("Smart previews")); let path = loc["path"].as_str().unwrap_or_default().to_string(); let available = loc["available"].as_bool().unwrap_or(true); let custom = loc["custom"].as_bool().unwrap_or(false); - row(ui, t, "Folder", |ui| { + row(ui, t, crate::i18n::tr("Folder"), |ui| { let text = format!("{path}{}", if custom { "" } else { " (library default)" }); ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text); }); let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0)); if available { - hint(ui, t, &format!("{count} smart preview{} · {:.1} MB", if count == 1 { "" } else { "s" }, bytes as f64 / 1048576.0)); + hint( + ui, + t, + &crate::i18n::tr_format!( + "{count} smart preview{} · {:.1} MB", + if count == 1 { "" } else { "s" }, + bytes as f64 / 1048576.0, + count = count + ), + ); } else { hint( ui, t, - "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.", + crate::i18n::tr( + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.", + ), ); } // what happens to the previews already built when the folder changes let mode_id = egui::Id::new("smart-existing"); let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0); if count > 0 { - row(ui, t, "Existing previews", |ui| { + row(ui, t, crate::i18n::tr("Existing previews"), |ui| { if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) { ui.data_mut(|d| d.insert_temp(mode_id, mode)); } @@ -447,7 +467,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.horizontal(|ui| { ui.add_space(LABEL_W); if app.services.pick_folder.is_some() { - let r = ui.button("Choose Folder…"); + let r = ui.button(crate::i18n::tr("Choose Folder…")); register(ui.ctx(), "button:settingsSmartChoose", r.rect); if r.clicked() && let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f()) @@ -455,7 +475,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing}))); } } - let r = ui.add_enabled(custom, egui::Button::new("Use Library Folder")); + let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder"))); register(ui.ctx(), "button:settingsSmartReset", r.rect); if r.clicked() { result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing}))); @@ -472,22 +492,22 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ("discard", n) => format!("Smart previews folder changed; deleted {n}"), _ => "Smart previews folder changed".to_string(), }; - app.toast(ui.ctx(), if failed > 0 { format!("{msg} ({failed} failed)") } else { msg }); + app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg }); } Err(e) => app.toast(ui.ctx(), e), } } - hint(ui, t, "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library."); + hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.")); } // ---------------------------------------------------------------------------------- Interface fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - heading(ui, t, "Filmstrip"); + heading(ui, t, crate::i18n::tr("Filmstrip")); check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); - heading(ui, t, "Grid"); - row(ui, t, "Ratings & flags", |ui| { + heading(ui, t, crate::i18n::tr("Grid")); + row(ui, t, crate::i18n::tr("Ratings & flags"), |ui| { choices( ui, "settingsGridBadges", @@ -496,9 +516,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ); }); check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); - heading(ui, t, "Detail"); + heading(ui, t, crate::i18n::tr("Detail")); check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); - row(ui, t, "Info overlay", |ui| { + row(ui, t, crate::i18n::tr("Info overlay"), |ui| { use crate::state::InfoOverlay as I; choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay); }); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index e1aaacf7..e960909e 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -19,7 +19,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field - let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui, app.ui.language) + 24.0; + let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); } let search_left = full.center().x - sw / 2.0; @@ -59,7 +59,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); let empty = app.ui.search.is_empty(); if empty && !focused { - let g = child.painter().layout_no_wrap("Search Photos".into(), t.font(13.5), t.text_dim); + let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim); let w = g.size().x + 24.0; let x0 = sr.center().x - w / 2.0; paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim); @@ -102,7 +102,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas); } let fresp = fresp.on_hover_text(if active > 0 { - format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }) + crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) } else { "Filter bar".into() }); @@ -113,10 +113,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut x = full.right() - 18.0; // saving is failing: the cloud icon turns into a warning until a save succeeds let unsaved = app.session.unsaved().map(|(n, e)| { - format!( + crate::i18n::tr_format!( "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.", if n == 1 { "" } else { "s" }, - if n == 1 { "it" } else { "them" } + if n == 1 { "it" } else { "them" }, + e = e, + n = n ) }); let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ae275ac2..07665dad 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -166,7 +166,8 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(rest) = f.strip_prefix("rate:") { let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); - let label = if n == "0" { "Rating cleared".to_string() } else { format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) }; + let label = + if n == "0" { "Rating cleared".to_string() } else { crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) }; app.toast(ctx, label); } else if let Some(l) = f.strip_prefix("label:") { cull(app, "photo.label", json!({"label": l}), false); @@ -217,7 +218,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { if let Ok(r) = app.run("album.toggleTarget", json!({})) { let n = app.session.targets(&json!({})).len(); - let what = if n == 1 { "photo".to_string() } else { format!("{n} photos") }; + let what = if n == 1 { "photo".to_string() } else { crate::i18n::tr_format!("{n} photos", n = n) }; let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); app.toast(ctx, if r["added"] == true { format!("Added {what} to {name}") } else { format!("Removed {what} from {name}") }); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e295dee3..1a4446e3 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -200,7 +200,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { - #[serde(default)] + #[serde(default = "crate::i18n::default_language")] pub language: crate::i18n::Language, /// The Build Previews run last announced (its identity, finished?). #[serde(skip)] @@ -513,7 +513,7 @@ pub enum Dialog { impl Default for UiState { fn default() -> Self { UiState { - language: crate::i18n::Language::default(), + language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, luminance_map_restore: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 91f044f9..a62e8313 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -110,17 +110,16 @@ pub fn install_fonts(ctx: &egui::Context) { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")))); + fonts.font_data.insert("Japanese".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/BIZUDPGothic-Regular.ttf")))); + fonts.font_data.insert("Japanese-Bold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/BIZUDPGothic-Bold.ttf")))); let fallback: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); - let mut prop = vec!["Inter".to_string()]; + let mut prop = vec!["Inter".to_string(), "Japanese".to_string()]; prop.extend(fallback.clone()); fonts.families.insert(FontFamily::Proportional, prop); - let mut semi = vec!["Inter-SemiBold".to_string()]; + let mut semi = vec!["Inter-SemiBold".to_string(), "Japanese-Bold".to_string()]; semi.extend(fallback); fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi); - fonts.font_data.insert("japanese".into(), Arc::new(FontData::from_static(lightcraft_engine::export::WATERMARK_JAPANESE_FONT))); - for stack in fonts.families.values_mut() { - stack.push("japanese".into()); - } + fonts.families.entry(FontFamily::Monospace).or_default().push("Japanese".into()); ctx.set_fonts(fonts); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f84565d1..d89a9ce3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -54,13 +54,13 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) { ui.horizontal(|ui| { let (ir, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); paint(ui.painter(), ir, Icon::Info, t.caution); - ui.label(egui::RichText::new("Preview only").font(t.semibold(12.5)).color(t.text)); + ui.label(egui::RichText::new(crate::i18n::tr("Preview only")).font(t.semibold(12.5)).color(t.text)); }); ui.add_space(2.0); ui.label(egui::RichText::new(preview_only_explanation(reason)).size(11.5).color(t.text_label)); }) .response; - let r = r.on_hover_text(format!("Decoder: {reason}")); + let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason)); r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Preview only: editing the camera's embedded JPEG")); register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect); } @@ -155,7 +155,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let id = ui.id().with(spec.id); let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); // screen readers: a slider named after its control, with its value - let label_text = label_override.unwrap_or(spec.label).to_string(); + let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); @@ -223,7 +223,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let hovered = resp.hovered() || resp.dragged(); let text_c = if enabled { t.text_label } else { t.text_disabled }; let p = ui.painter(); - p.text(label_rect.left_center(), Align2::LEFT_CENTER, label_override.unwrap_or(spec.label), t.font(12.5), text_c); + p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c); let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") }; let shown = if shown == "+0" || shown == "-0" { "0".to_string() } else { shown }; p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown, t.font(12.5), text_c); @@ -256,6 +256,7 @@ pub fn nudged(spec: &ControlSpec, value: f64, steps: f64) -> f64 { /// Collapsible section header ("› Light"). Returns the response (click toggles). pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: Option) -> (Response, Option) { + let title = crate::i18n::tr(title); let t = Tokens::get(ui.ctx()); let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click()); @@ -296,6 +297,7 @@ pub fn divider(ui: &mut Ui) { /// A flyout row inside a section (Curve, Color Mixer, Color Grading…): inset dark box with icon tile. pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> Response { + let title = crate::i18n::tr(title); let t = Tokens::get(ui.ctx()); let w = ui.available_width(); let (outer, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); @@ -320,6 +322,7 @@ pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> /// A small bordered text button (Auto, B&W, HDR…). pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response { + let label = if id.starts_with("labelSet-") { label } else { crate::i18n::tr(label) }; let t = Tokens::get(ui.ctx()); let font = t.semibold(11.5); let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text); @@ -353,6 +356,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option Response { + let tooltip = crate::i18n::tr(tooltip); let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() }); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, active, tooltip)); diff --git a/docs/localization-ja.md b/docs/localization-ja.md new file mode 100644 index 00000000..1cd41b46 --- /dev/null +++ b/docs/localization-ja.md @@ -0,0 +1,21 @@ +# LightCraft 日本語表示 + +LightCraft は日本語と英語(English)の表示に対応しています。 +言語設定は `ui.json` の `language` に保存します。 + +- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。 +- メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。 +- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを組み込みました。 +- 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。 +- 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。 + +## 翻訳の保守 + +静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は +`crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。 +可変文言の両言語はビルド時にRustのフォーマット検査を受けます。 +英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 +`LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 + +表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は +`cargo test -p lightcraft-ui-egui i18n::tests` で検証します。 diff --git a/docs/parity.md b/docs/parity.md index a32161ac..ed4b2f27 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -505,7 +505,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 | ⬜ | | | +| 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-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 | ⬜ | | |