M16: add Japanese UI localization and isolated local macOS packaging

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
arata1972 2026-10-06 05:14:35 +09:00
parent 294012742e
commit 7c799a9acf
43 changed files with 2145 additions and 539 deletions

View File

@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; 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; local 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)

View File

@ -4,7 +4,7 @@
//!
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else
//! `~/Pictures/LightCraft Library`; a new library starts with the
//! `~/Pictures/LightCraft Japanese Library`; a new library starts with the
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
//! nothing (demo photos unless files are given; used by the README showcase scripts).
@ -67,7 +67,7 @@ impl eframe::App for App {
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/LightCraft"))
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/LightCraft Japanese"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("LightCraft"))
} else {
@ -101,7 +101,9 @@ fn save_prefs(app: &LightcraftApp) {
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();
@ -153,7 +155,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",
@ -167,8 +169,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()
@ -177,24 +182,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()
@ -203,8 +208,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())
@ -264,7 +269,7 @@ lightcraft — photo library + raw developer
USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported)
OPTIONS:
--library DIR open (or create) this library (default: ~/Pictures/LightCraft Library; env LIGHTCRAFT_LIBRARY)
--library DIR open (or create) this library (default: ~/Pictures/LightCraft Japanese Library; env LIGHTCRAFT_LIBRARY)
--no-demo don't seed a new library with the procedural demo photos
--memory throwaway in-memory session (alias --demo); nothing is saved
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
@ -311,7 +316,12 @@ fn main() -> eframe::Result {
.filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none())
.map(Into::into)
});
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
let library_dir = library_dir.or_else(|| {
if let Some(path) = std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()) {
return Some(path.into());
}
std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join("Pictures/LightCraft Japanese Library"))
});
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
@ -332,9 +342,11 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
let mut app = LightcraftApp::new(session, services());
app.ui.settings.language = lightcraft_ui_egui::i18n::Language::Japanese;
if let Some(ui) = prefs {
app.ui = ui;
}
lightcraft_ui_egui::i18n::set_language(app.ui.settings.language);
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());

View File

@ -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<MenuNode>)]) -> String {
@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> 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, "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);
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(),
]);
}
@ -247,10 +247,16 @@ impl NativeMenu {
self.append_nodes(&sub, &nodes);
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 == "Window" {
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
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 == "Help" {
@ -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.settings.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();
}

View File

@ -19,6 +19,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.

Binary file not shown.

Binary file not shown.

View File

@ -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.

View File

@ -25,3 +25,6 @@ log = { workspace = true }
[dev-dependencies]
lightcraft-codecs = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }

18
crates/ui-egui/build.rs Normal file
View File

@ -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<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=locales/ja-formats.json");
let messages: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?;
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(())
}

View File

@ -0,0 +1,101 @@
{
" · {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}件"
}

View File

@ -0,0 +1,952 @@
{
"+ 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:": "以下の条件に一致:"
}

View File

@ -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() {

142
crates/ui-egui/src/i18n.rs Normal file
View File

@ -0,0 +1,142 @@
//! Presentation-only localization. Command ids, user names and catalog data remain unchanged.
use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
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")]
English,
#[serde(rename = "ja")]
Japanese,
}
thread_local! {
static LANGUAGE: Cell<Language> = const { Cell::new(Language::English) };
}
pub fn default_language() -> Language {
if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Japanese } else { Language::English }
}
pub fn set_language(language: Language) {
LANGUAGE.with(|value| value.set(language));
}
pub fn is_japanese() -> bool {
LANGUAGE.with(|value| value.get() == Language::Japanese)
}
fn japanese() -> &'static BTreeMap<String, String> {
static MESSAGES: OnceLock<BTreeMap<String, String>> = 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 catalog_is_valid_and_contains_core_workflows() {
let messages: BTreeMap<String, String> = 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 language_switches_and_unknown_text_survives() {
set_language(Language::Japanese);
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::English);
assert_eq!(tr("Exposure"), "Exposure");
}
#[test]
fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() {
set_language(Language::Japanese);
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::English);
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::AppSettings = serde_json::from_str("{}").unwrap();
assert_eq!(old.language, Language::English);
let settings = crate::state::AppSettings { language: Language::Japanese, ..old };
let saved = serde_json::to_string(&settings).unwrap();
let restored: crate::state::AppSettings = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Japanese);
}
#[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.settings.language = Language::Japanese;
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)),
_ => {}
}
}
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::<Vec<_>>();
let japanese_ids = ids(&app);
set_language(Language::English);
assert_eq!(japanese_ids, ids(&app));
}
}

View File

@ -256,19 +256,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);
@ -372,11 +374,11 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
}
let Some(task) = app.import.take() else { return };
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.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;
}
@ -384,18 +386,18 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let _ = app.run("library.select", json!({"ids": [f]}));
}
let plural = |n: usize| if n == 1 { "" } else { "s" };
let mut msg = format!("Imported {} photo{}", task.imported, plural(task.imported));
let mut msg = 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);
}
@ -406,13 +408,15 @@ 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 = format!("{} photos… {} of {}", if task.browse { "Reading" } else { "Adding" }, task.done, task.total);
egui::Window::new("Importing").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 60.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, 60.0])
.show(ctx, |ui| {
ui.label(egui::RichText::new(text).color(t.text));
ui.add(egui::ProgressBar::new(frac).desired_width(320.0));
},
);
});
}
/// The dialog body: options, then the candidate grid.
@ -422,15 +426,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);
}
@ -462,25 +466,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;
}
@ -505,12 +516,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();
}
});
@ -557,14 +568,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);
});
}
@ -582,10 +595,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(),
);
}
}
@ -602,11 +617,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();
@ -627,7 +642,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 {
@ -641,7 +656,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 {
@ -653,7 +668,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);
});
}
@ -726,7 +741,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::<bool>(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));
@ -770,7 +786,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| {
@ -848,9 +866,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),
}
}
}

View File

@ -9,6 +9,7 @@
pub mod control;
pub mod export_task;
pub mod headless;
pub mod i18n;
pub mod icons;
pub mod import;
pub mod links;
@ -438,6 +439,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.settings.language);
let t0 = now_ms();
self.logic_inner(ctx);
self.perf.logic_ms = now_ms() - t0;
@ -585,6 +587,7 @@ impl LightcraftApp {
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
i18n::set_language(self.ui.settings.language);
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();

View File

@ -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"];
@ -294,11 +303,15 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String {
let n = app.session.selection.ids.len();
match id {
"edit.undo" => app.session.undo.last().map(|e| 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(),
}
}
@ -462,14 +475,21 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
.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
}
@ -667,17 +687,17 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
let saved = ui.spacing().item_spacing.x;
ui.spacing_mut().item_spacing.x = TITLE_GAP;
for (title, items) in &bar {
let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false));
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("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));
}
});
}
@ -695,11 +715,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));
}

View File

@ -147,10 +147,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;
}
@ -859,7 +859,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
(
"GPU".to_string(),
gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() {
Some(why) => format!("none (CPU rendering): {why}"),
Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why),
None => "none (CPU rendering)".into(),
}),
),
@ -1053,8 +1053,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
let failed = v["failed"].as_array().map_or(0, Vec::len);
let mut msg = match (n, failed) {
(0, 0) => "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"]
@ -1099,7 +1105,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
let r = app.session.execute("preset.export", &params).map_err(|e| e.to_string());
if let Ok(v) = &r {
app.toast(&ctx, format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
app.toast(&ctx, crate::i18n::tr_format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
}
return Some(r);
}
@ -1118,9 +1124,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
if let Ok(v) = &r {
let n = v["imported"].as_array().map_or(0, Vec::len);
let failed = v["failed"].as_array().map_or(0, Vec::len);
let mut msg = format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" });
let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n);
if failed > 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);
}
@ -1141,7 +1147,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
let r = app.session.execute("curve.exportPresets", &params).map_err(|e| e.to_string());
if let Ok(v) = &r {
app.toast(&ctx, format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
}
return Some(r);
}

View File

@ -257,18 +257,18 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) {
Ok(v) => {
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));
});
});
}

View File

@ -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}));
}
}

View File

@ -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<usize>, 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!({}));
}

View File

@ -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;
}

View File

@ -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);

View File

@ -52,7 +52,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)
@ -67,7 +67,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)
@ -83,15 +83,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| {
@ -120,14 +120,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);
@ -170,7 +170,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| {
@ -183,22 +183,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),
);
}
@ -252,7 +252,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 } => {
@ -278,17 +278,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;
@ -313,7 +313,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 = app.session.execute("photo.renamePreview", &json!({"template": template, "start": start})).unwrap_or_default();
@ -327,12 +327,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
let more = preview.as_array().map_or(0, Vec::len).saturating_sub(6);
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)) {
@ -348,7 +348,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::<Vec<_>>().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)) {
@ -377,23 +377,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);
@ -401,11 +401,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 {
@ -434,7 +434,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() {
@ -458,7 +458,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(
@ -517,10 +517,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 {
@ -538,7 +538,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;
@ -571,7 +571,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"));
}
}
}
@ -604,7 +604,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()
{
@ -614,7 +614,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(
@ -628,17 +628,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()));
});
});
}
@ -646,14 +646,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)
@ -680,7 +680,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();
@ -688,7 +688,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();
@ -701,7 +701,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();
@ -713,7 +713,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;
@ -729,7 +729,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 {
@ -928,15 +928,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);
@ -969,7 +969,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;
@ -998,7 +998,7 @@ fn field<R>(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)
})

View File

@ -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}));
}
}

View File

@ -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<String> = 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");
}
});

View File

@ -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<u64> = 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<u64> = 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,32 +871,32 @@ 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
let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if !std::path::Path::new(path).exists());
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),
}
@ -892,7 +904,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!({}));
}
}

View File

@ -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() {
@ -231,9 +232,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!({}));
@ -384,20 +385,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(),
@ -405,7 +412,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})) {
@ -415,7 +422,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);
@ -489,7 +496,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}));
@ -512,7 +519,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;
@ -521,27 +528,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 {
@ -551,12 +558,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);
@ -573,8 +580,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 {
@ -583,10 +590,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}));
}
});
@ -620,7 +627,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);
}
@ -656,20 +663,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}));
}
});

View File

@ -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;
}
});

View File

@ -81,6 +81,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);
}

View File

@ -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<String> = 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}));
}
});

View File

@ -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() {

View File

@ -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);
@ -549,7 +567,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!({}));
}
});
@ -609,10 +630,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()}));
}
}
@ -647,7 +668,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::<String>(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<String> = text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
@ -665,17 +686,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}));
}
});
@ -689,12 +710,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) }}));
@ -707,7 +731,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
@ -732,7 +756,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(&current).show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
let name = set["name"].as_str().unwrap_or_default();
@ -741,7 +765,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(),
@ -751,14 +775,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<String> = 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);
@ -788,7 +814,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);
@ -797,7 +823,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;
@ -840,7 +866,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");
@ -849,20 +876,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}));
}
},
@ -892,7 +919,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());
@ -906,15 +933,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!({}));
}
});

View File

@ -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);

View File

@ -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

View File

@ -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<R>(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<R>(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,36 @@ fn choices<V: PartialEq + Copy>(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::Japanese, "日本語"), (crate::i18n::Language::English, "English")],
&mut app.ui.settings.language,
);
crate::i18n::set_language(app.ui.settings.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 +127,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 +188,9 @@ fn cameras(app: &LightcraftApp) -> Vec<String> {
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 +204,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 +224,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 +244,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 +271,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 +293,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 +307,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 +320,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::<String>(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 +343,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 +351,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 +383,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 +402,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 +428,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 +472,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 +480,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 +497,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 +521,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);
});

View File

@ -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");

View File

@ -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::<u8>().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}") });
}

View File

@ -131,6 +131,8 @@ impl InfoOverlay {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AppSettings {
/// Display language, independent of catalog data and command ids.
pub language: crate::i18n::Language,
/// Library opened at launch when no `--library` is given (empty = the default location).
pub library_path: String,
pub startup_view: StartupView,
@ -155,6 +157,7 @@ pub struct AppSettings {
impl Default for AppSettings {
fn default() -> Self {
AppSettings {
language: crate::i18n::default_language(),
library_path: String::new(),
startup_view: StartupView::Last,
confirm_delete: false,

View File

@ -110,13 +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<String> = 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.families.entry(FontFamily::Monospace).or_default().push("Japanese".into());
ctx.set_fonts(fonts);
}

View File

@ -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<bool>) -> (Response, Option<bool>) {
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<u
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = gap;
for (j, (label, key)) in row.iter().enumerate() {
let label = crate::i18n::tr(label);
let i = row_i * per_row + j;
let (r, resp) = ui.allocate_exact_size(vec2(seg_w, 24.0), Sense::click());
register(ui.ctx(), format!("button:{id}-{key}"), r);
@ -365,7 +369,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<u
};
let p = ui.painter();
p.rect(r, CornerRadius::same(4), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, *label, t.semibold(11.5), t.text);
p.text(r.center(), Align2::CENTER_CENTER, label, t.semibold(11.5), t.text);
if resp.clicked() {
clicked = Some(i);
}
@ -377,6 +381,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<u
/// An icon-only button. `active` draws the selected background (tool strip).
pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: bool, enabled: bool, tooltip: &str) -> 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));

28
docs/localization-ja.md Normal file
View File

@ -0,0 +1,28 @@
# LightCraft 日本語版
このローカル版は、LightCraftを基に日本語表示を追加したものです。
- 「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。
- メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。
- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを組み込みました。
- 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。
- 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。
## 保存場所
日本語版アプリは「LightCraft 日本語.app」です。既存の公式版とは別に配置します。
初期ライブラリは `~/Pictures/LightCraft Japanese Library`、設定は
`~/Library/Application Support/LightCraft Japanese/ui.json` に保存します。
既存のライブラリを使用する場合は、そのライブラリを使用中の公式版を閉じてから、
日本語版の「ファイル → ライブラリを開く…」で選びます。
## 翻訳の保守
静的な表示文言は `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` で検証します。

View File

@ -504,7 +504,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` | Local 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 | ⬜ | | |

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Package the locally built arm64 app with an independent name and bundle identifier."""
import argparse
import pathlib
import plistlib
import shutil
import subprocess
root = pathlib.Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=pathlib.Path, required=True)
options = parser.parse_args()
app = options.output.resolve()
if app.exists():
raise SystemExit(f"Output already exists: {app}")
for name in ("lightcraft", "lightcraft-cli"):
if not (root / "target/release" / name).is_file():
raise SystemExit(f"Build {name} first with cargo build --release")
macos = app / "Contents/MacOS"
resources = app / "Contents/Resources"
macos.mkdir(parents=True)
resources.mkdir(parents=True)
template = (root / "packaging/macos/Info.plist.in").read_text()
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip()
template = template.replace("@VERSION@", "0.2.0-ja.1").replace("@SHORT_VERSION@", "0.2.0").replace("@BUILD_SHA@", revision)
info = plistlib.loads(template.encode())
info.update({
"CFBundleDevelopmentRegion": "ja",
"CFBundleDisplayName": "LightCraft 日本語",
"CFBundleName": "LightCraft 日本語",
"CFBundleIdentifier": "local.taigi.lightcraft.japanese",
"CFBundleLocalizations": ["ja", "en"],
"NSHumanReadableCopyright": "Based on LightCraft by its contributors. Japanese localization added locally. MIT OR Apache-2.0.",
})
# This local build is an editor, not a competing system document-type owner.
info.pop("UTExportedTypeDeclarations", None)
info.pop("UTImportedTypeDeclarations", None)
(app / "Contents/Info.plist").write_bytes(plistlib.dumps(info))
shutil.copy2(root / "target/release/lightcraft", macos / "LightCraft")
shutil.copy2(root / "target/release/lightcraft-cli", macos / "lightcraft-cli")
shutil.copy2(root / "assets/app-icon/lightcraft.icns", resources / "LightCraft.icns")
licenses = resources / "Licenses"
licenses.mkdir()
for source in ("LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "assets/fonts/OFL-Inter.txt", "assets/fonts/OFL-BIZUDGothic.txt", "assets/app-icon/LICENSE.txt"):
shutil.copy2(root / source, licenses / ("Icon-LICENSE.txt" if source == "assets/app-icon/LICENSE.txt" else pathlib.Path(source).name))
shutil.copy2(root / "docs/localization-ja.md", resources / "日本語版について.md")
subprocess.run(["codesign", "--force", "--sign", "-", "--timestamp=none", str(macos / "lightcraft-cli")], check=True)
subprocess.run(["codesign", "--force", "--sign", "-", "--timestamp=none", str(app)], check=True)
subprocess.run(["codesign", "--verify", "--deep", "--strict", str(app)], check=True)
print(app)