Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc3fa1a71b | ||
|
|
d0612b0b21 | ||
|
|
66dc737ff5 | ||
|
|
fec3b15234 | ||
|
|
11ebb3bdb6 | ||
|
|
65da36675f | ||
|
|
cbb6c3b8f7 | ||
|
|
5b414ffb8e | ||
|
|
f7a7831380 | ||
|
|
2b2ade7a23 | ||
|
|
d6c7228dd4 | ||
|
|
4b3048c48f | ||
|
|
bc4799ca42 | ||
|
|
586479a309 | ||
|
|
87f275b58e | ||
|
|
894759e6d8 | ||
|
|
1952f7c960 | ||
|
|
7bbdbc03ef | ||
|
|
0f34a65a8c | ||
|
|
6c066b66f2 | ||
|
|
7b59081d22 | ||
|
|
e11f77d9ca | ||
|
|
f383d1e736 | ||
|
|
edf05789ed | ||
|
|
bccccfb92a | ||
|
|
4939d9b994 | ||
|
|
e0ab41ede0 | ||
|
|
8690b51fcf | ||
|
|
e1d3b56bb6 | ||
|
|
6100237cdb | ||
|
|
4579599745 | ||
|
|
8506fecf5f | ||
|
|
50cd8dc6dc | ||
|
|
cc8b3eb3bb | ||
|
|
a6b402981b | ||
|
|
4deb5eff04 | ||
|
|
6e2408ff7d | ||
|
|
66be31e3d8 | ||
|
|
332f37a5c7 | ||
|
|
aa1af6d5e9 | ||
|
|
963912b231 | ||
|
|
4174ce8c57 | ||
|
|
f28e1c362e | ||
|
|
70d800ba68 | ||
|
|
33ef91a123 | ||
|
|
f1b29cfe4a | ||
|
|
130b807e1b | ||
|
|
43f4e24355 | ||
|
|
5e618c553a | ||
|
|
cbebabccad | ||
|
|
1a05c3eed2 | ||
|
|
ee4b78f646 | ||
|
|
d592b29d12 | ||
|
|
cddcb905e0 | ||
|
|
ebc73bb7b4 | ||
|
|
509d5843dd | ||
|
|
05f242c445 | ||
|
|
87e0181690 | ||
|
|
7c799a9acf |
No files matched your search
@@ -47,6 +47,10 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
|
||||
@@ -221,8 +221,9 @@ jobs:
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|
||||
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
|
||||
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
fi
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
|
||||
Generated
+23
-22
@@ -2073,7 +2073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2091,7 +2091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-catalog"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"lightcraft-develop",
|
||||
@@ -2105,7 +2105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"lightcraft-codecs",
|
||||
@@ -2120,7 +2120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"image",
|
||||
@@ -2145,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
@@ -2154,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-develop"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
@@ -2165,7 +2165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"libc",
|
||||
@@ -2193,7 +2193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2201,7 +2201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"lightcraft-color",
|
||||
@@ -2217,7 +2217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
@@ -2228,7 +2228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-merge"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-color",
|
||||
@@ -2244,7 +2244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-tiff",
|
||||
@@ -2256,7 +2256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-pipeline"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2272,7 +2272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-preview"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-raster",
|
||||
@@ -2284,7 +2284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"lightcraft-color",
|
||||
@@ -2296,7 +2296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raw"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"lightcraft-color",
|
||||
@@ -2315,7 +2315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-scenes"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"lightcraft-color",
|
||||
@@ -2326,11 +2326,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2339,7 +2339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"lightcraft-catalog",
|
||||
@@ -2359,8 +2359,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
@@ -5127,7 +5128,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
+8
-7
@@ -24,19 +24,19 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No camera colour calibration**: non-DNG raws use a neutral matrix, so colour is muted/inaccurate. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; no localisation; accessibility partial; headless UI tests time out under machine load |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
| User | Readiness | What blocks them |
|
||||
|---|---:|---|
|
||||
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
|
||||
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour (muted colour on every photo) |
|
||||
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
|
||||
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
|
||||
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
|
||||
@@ -46,7 +46,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Raises colour quality for every non-DNG raw at once.
|
||||
matrices the files carry themselves, then chart shots. Sony ARW's file-local fit (matrix + tone curve from its own
|
||||
JPEG) is the first step; generalise it to the other makes' raws (NEF, RW2, PEF, ORF…), then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
@@ -81,12 +82,12 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
||||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** ⬜ (top priority), CR3, compressed ORF/RAF ⬜) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| 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)
|
||||
|
||||
@@ -175,7 +176,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
||||
from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
|
||||
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
|
||||
**Camera colour matrices:** non-DNG raws use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
@@ -91,6 +91,17 @@ USAGE:
|
||||
#[global_allocator]
|
||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
|
||||
/// that one instead (issue #99).
|
||||
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
match e {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
|
||||
),
|
||||
e => format!("{dir}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// `--features dhat-heap`: count allocations; the profile is written when `_heap` drops
|
||||
// (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json).
|
||||
@@ -182,7 +193,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
|
||||
h
|
||||
}
|
||||
@@ -419,7 +430,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
@@ -586,7 +597,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(dir, false).map_err(|e| format!("{dir}: {e}"))?;
|
||||
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
|
||||
s
|
||||
}
|
||||
None if files.is_empty() => Session::with_demo().with_fs(),
|
||||
|
||||
@@ -260,3 +260,31 @@ fn devices_are_listed_and_imported_from() {
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
|
||||
}
|
||||
|
||||
/// Issue #99: a library open in one process (here `mcp --library`) is refused by a second one,
|
||||
/// with who has it and how to drive the running app instead; free again once the first exits.
|
||||
#[test]
|
||||
fn a_library_open_in_another_process_is_refused() {
|
||||
let lib = tmp("locked-lib");
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
let lib_s = lib.to_str().unwrap();
|
||||
let mut holder =
|
||||
Command::new(BIN).args(["mcp", "--library", lib_s]).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut err = BufReader::new(holder.stderr.take().unwrap());
|
||||
let mut line = String::new();
|
||||
while !line.contains("opened library") {
|
||||
line.clear();
|
||||
assert!(err.read_line(&mut line).unwrap() > 0, "mcp exited before opening the library");
|
||||
}
|
||||
let (ok, _, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(!ok);
|
||||
assert!(stderr.contains("already open in lightcraft-cli") && stderr.contains(&format!("process {}", holder.id())), "{stderr}");
|
||||
assert!(stderr.contains("mcp --connect"), "{stderr}");
|
||||
|
||||
drop(holder.stdin.take()); // EOF: the server exits and lets go of the library
|
||||
assert!(holder.wait().unwrap().success());
|
||||
let (ok, lines, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(ok, "{stderr}");
|
||||
assert_eq!(lines[0]["ok"], true);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow"] }
|
||||
|
||||
@@ -232,6 +232,18 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is there a file at `path`?
|
||||
pub async fn exists(&self, path: &str) -> Result<bool, String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => Ok(Self::opfs_file(root, path, false).await?.is_some()),
|
||||
Backend::Idb(db) => {
|
||||
let (_tx, store) = Self::idb_store(db, false)?;
|
||||
let n = idb_req(&store.count_with_key(&path.into()).map_err(err)?).await?;
|
||||
Ok(n.as_f64().is_some_and(|n| n > 0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, path: &str) -> Result<(), String> {
|
||||
match self {
|
||||
Backend::Opfs(root) => {
|
||||
@@ -309,14 +321,25 @@ async fn dir_keys(dir: &FileSystemDirectoryHandle) -> Result<Vec<String>, String
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ask the browser not to evict our storage under pressure (best effort; fire and forget).
|
||||
pub fn request_persistence() {
|
||||
if let Some(sm) = storage_manager()
|
||||
&& let Ok(p) = sm.persist()
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let granted = JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy());
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted (best effort)" });
|
||||
});
|
||||
}
|
||||
/// Ask the browser not to evict our storage under pressure; `on_result(granted)` once known
|
||||
/// (`false` also when the browser has no storage manager).
|
||||
pub fn request_persistence(on_result: impl FnOnce(bool) + 'static) {
|
||||
let Some(sm) = storage_manager() else {
|
||||
on_result(false);
|
||||
return;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// already persistent: nothing to ask
|
||||
let already = match sm.persisted() {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
let granted = already
|
||||
|| match sm.persist() {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted" });
|
||||
on_result(granted);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Library backups for the browser build: a plain zip (entries stored, not compressed — photos
|
||||
//! don't shrink) of the library files and every stored original, built one entry at a time so
|
||||
//! only one file is in wasm memory at once, and read back the same way for a restore.
|
||||
//!
|
||||
//! Layout of a backup:
|
||||
//! - `library/catalog.snap`, `library/catalog.log`, `library/presets.json`, … (see
|
||||
//! [`crate::files::LIBRARY_FILES`]);
|
||||
//! - `originals/<content hash>/<file name>`: each imported photo's original bytes, under its own
|
||||
//! name so an unzipped backup is browsable;
|
||||
//! - `README.txt`.
|
||||
//!
|
||||
//! Restoring never deletes anything: the library files go to a new library folder in browser
|
||||
//! storage, originals that aren't stored yet are added (they're named by content, so existing
|
||||
//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to
|
||||
//! the restored library. The library that was there before stays in storage.
|
||||
//!
|
||||
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
|
||||
|
||||
/// Storage key of the file naming the library folder in use (absent: `library`).
|
||||
pub const ACTIVE_LIBRARY: &str = "active-library";
|
||||
|
||||
/// The default library folder.
|
||||
pub const DEFAULT_LIBRARY_DIR: &str = "library";
|
||||
|
||||
/// Largest backup (offsets are 32-bit without zip64).
|
||||
pub const MAX_BYTES: u64 = 0xFFFF_FFFF - (64 << 20);
|
||||
|
||||
const LOCAL_SIG: u32 = 0x0403_4b50;
|
||||
const CENTRAL_SIG: u32 = 0x0201_4b50;
|
||||
const END_SIG: u32 = 0x0605_4b50;
|
||||
/// General-purpose flag bit 11: names are UTF-8.
|
||||
const UTF8: u16 = 0x0800;
|
||||
/// 1980-01-01 00:00 in MS-DOS format (date, time).
|
||||
const DOS_DATE: u16 = (1 << 5) | 1;
|
||||
|
||||
/// The readme inside every backup.
|
||||
pub const README: &str = "LightCraft library backup (browser version)\n\n\
|
||||
library/ the catalog (catalog.snap + catalog.log), presets, view state and preferences\n\
|
||||
originals/ every imported photo, as originals/<content hash>/<file name>\n\n\
|
||||
Restore it in LightCraft for the web with File > Restore Library from Backup...\n\
|
||||
The photos in originals/ are the untouched files you imported; edits live in the catalog.\n";
|
||||
|
||||
/// Is `name` a safe library folder name (`library` or `library-<letters, digits, ->`)?
|
||||
pub fn valid_library_dir(name: &str) -> bool {
|
||||
name == DEFAULT_LIBRARY_DIR
|
||||
|| name.strip_prefix("library-").is_some_and(|s| !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
}
|
||||
|
||||
/// Is `h` a plausible content hash (used as a storage key segment)?
|
||||
pub fn valid_hash(h: &str) -> bool {
|
||||
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Zip entry name for an original.
|
||||
pub fn original_entry(hash: &str, file_name: &str) -> String {
|
||||
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
|
||||
let name = if name.is_empty() || name == "." || name == ".." { "original".to_string() } else { name };
|
||||
format!("originals/{hash}/{name}")
|
||||
}
|
||||
|
||||
/// Where a backup entry goes when restoring into library folder `dir`: `Some(storage key)`, or
|
||||
/// `None` to skip it (README, thumbnails, anything unknown or unsafe).
|
||||
pub fn restore_key(entry: &str, dir: &str) -> Option<String> {
|
||||
if let Some(name) = entry.strip_prefix("library/") {
|
||||
return crate::files::LIBRARY_FILES.contains(&name).then(|| format!("{dir}/{name}"));
|
||||
}
|
||||
let rest = entry.strip_prefix("originals/")?;
|
||||
let (hash, _) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
valid_hash(hash).then(|| format!("originals/{hash}"))
|
||||
}
|
||||
|
||||
fn u16le(v: &mut Vec<u8>, x: u16) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
fn u32le(v: &mut Vec<u8>, x: u32) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
struct Central {
|
||||
name: String,
|
||||
crc: u32,
|
||||
size: u32,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
/// Writes a stored (uncompressed) zip piece by piece: [`ZipWriter::entry`] returns the local
|
||||
/// header to emit before the entry's bytes, [`ZipWriter::finish`] the central directory.
|
||||
#[derive(Default)]
|
||||
pub struct ZipWriter {
|
||||
entries: Vec<Central>,
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
impl ZipWriter {
|
||||
/// The local header for `name` holding `data` (emit it, then `data`).
|
||||
pub fn entry(&mut self, name: &str, data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let size = u32::try_from(data.len()).map_err(|_| format!("{name}: larger than 4 GB"))?;
|
||||
let name_len = u16::try_from(name.len()).map_err(|_| format!("{name}: name too long"))?;
|
||||
if self.entries.len() >= usize::from(u16::MAX) {
|
||||
return Err("too many files for one backup".into());
|
||||
}
|
||||
let header_len = 30 + u64::from(name_len);
|
||||
if self.offset + header_len + u64::from(size) > MAX_BYTES {
|
||||
return Err("the library is too large for one backup file (4 GB)".into());
|
||||
}
|
||||
let offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
|
||||
let crc = crc32fast::hash(data);
|
||||
let mut h = Vec::with_capacity(header_len as usize);
|
||||
u32le(&mut h, LOCAL_SIG);
|
||||
u16le(&mut h, 20); // version needed
|
||||
u16le(&mut h, UTF8);
|
||||
u16le(&mut h, 0); // stored
|
||||
u16le(&mut h, 0); // time
|
||||
u16le(&mut h, DOS_DATE);
|
||||
u32le(&mut h, crc);
|
||||
u32le(&mut h, size);
|
||||
u32le(&mut h, size);
|
||||
u16le(&mut h, name_len);
|
||||
u16le(&mut h, 0); // extra
|
||||
h.extend_from_slice(name.as_bytes());
|
||||
self.offset += header_len + u64::from(size);
|
||||
self.entries.push(Central { name: name.to_string(), crc, size, offset });
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The central directory and end record (emit last).
|
||||
pub fn finish(self) -> Result<Vec<u8>, String> {
|
||||
let mut cd = Vec::new();
|
||||
for e in &self.entries {
|
||||
u32le(&mut cd, CENTRAL_SIG);
|
||||
u16le(&mut cd, 20); // made by
|
||||
u16le(&mut cd, 20); // needed
|
||||
u16le(&mut cd, UTF8);
|
||||
u16le(&mut cd, 0);
|
||||
u16le(&mut cd, 0);
|
||||
u16le(&mut cd, DOS_DATE);
|
||||
u32le(&mut cd, e.crc);
|
||||
u32le(&mut cd, e.size);
|
||||
u32le(&mut cd, e.size);
|
||||
u16le(&mut cd, e.name.len() as u16);
|
||||
u16le(&mut cd, 0); // extra
|
||||
u16le(&mut cd, 0); // comment
|
||||
u16le(&mut cd, 0); // disk
|
||||
u16le(&mut cd, 0); // internal attrs
|
||||
u32le(&mut cd, 0); // external attrs
|
||||
u32le(&mut cd, e.offset);
|
||||
cd.extend_from_slice(e.name.as_bytes());
|
||||
}
|
||||
let cd_offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?;
|
||||
let cd_size = u32::try_from(cd.len()).map_err(|_| "backup too large".to_string())?;
|
||||
let n = self.entries.len() as u16;
|
||||
let mut out = cd;
|
||||
u32le(&mut out, END_SIG);
|
||||
u16le(&mut out, 0);
|
||||
u16le(&mut out, 0);
|
||||
u16le(&mut out, n);
|
||||
u16le(&mut out, n);
|
||||
u32le(&mut out, cd_size);
|
||||
u32le(&mut out, cd_offset);
|
||||
u16le(&mut out, 0); // comment
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of a zip's central directory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ZipEntry {
|
||||
pub name: String,
|
||||
pub crc: u32,
|
||||
pub size: u64,
|
||||
/// Offset of the entry's local header.
|
||||
pub header: u64,
|
||||
/// 0 = stored (the only method a restore reads).
|
||||
pub method: u16,
|
||||
}
|
||||
|
||||
fn rd16(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at + 2)?.try_into().ok()?))
|
||||
}
|
||||
fn rd32(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// How many bytes from the end of a zip to read to find its end record.
|
||||
pub fn tail_len(file_len: u64) -> u64 {
|
||||
file_len.min(22 + 65_535)
|
||||
}
|
||||
|
||||
/// The central directory's (offset, size) from the last [`tail_len`] bytes of a zip.
|
||||
pub fn find_central(tail: &[u8]) -> Result<(u64, u64), String> {
|
||||
let at = (0..tail.len().saturating_sub(21)).rev().find(|&i| rd32(tail, i) == Some(END_SIG)).ok_or("not a zip file (no end record)")?;
|
||||
let size = rd32(tail, at + 12).ok_or("truncated zip")?;
|
||||
let offset = rd32(tail, at + 16).ok_or("truncated zip")?;
|
||||
if size == u32::MAX || offset == u32::MAX {
|
||||
return Err("zip64 backups are not supported".into());
|
||||
}
|
||||
Ok((u64::from(offset), u64::from(size)))
|
||||
}
|
||||
|
||||
/// Parse a central directory.
|
||||
pub fn parse_central(cd: &[u8]) -> Result<Vec<ZipEntry>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 46 <= cd.len() {
|
||||
if rd32(cd, i) != Some(CENTRAL_SIG) {
|
||||
return Err("damaged zip directory".into());
|
||||
}
|
||||
let bad = || "damaged zip directory".to_string();
|
||||
let method = rd16(cd, i + 10).ok_or_else(bad)?;
|
||||
let crc = rd32(cd, i + 16).ok_or_else(bad)?;
|
||||
let size = rd32(cd, i + 24).ok_or_else(bad)?;
|
||||
let n = usize::from(rd16(cd, i + 28).ok_or_else(bad)?);
|
||||
let extra = usize::from(rd16(cd, i + 30).ok_or_else(bad)?);
|
||||
let comment = usize::from(rd16(cd, i + 32).ok_or_else(bad)?);
|
||||
let header = rd32(cd, i + 42).ok_or_else(bad)?;
|
||||
let name = cd.get(i + 46..i + 46 + n).ok_or_else(bad)?;
|
||||
out.push(ZipEntry { name: String::from_utf8_lossy(name).into_owned(), crc, size: u64::from(size), header: u64::from(header), method });
|
||||
i += 46 + n + extra + comment;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Offset of an entry's data from its local header's first 30 bytes.
|
||||
pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result<u64, String> {
|
||||
if rd32(local, 0) != Some(LOCAL_SIG) {
|
||||
return Err(format!("{}: damaged entry", entry.name));
|
||||
}
|
||||
let n = rd16(local, 26).ok_or("truncated entry")?;
|
||||
let extra = rd16(local, 28).ok_or("truncated entry")?;
|
||||
Ok(entry.header + 30 + u64::from(n) + u64::from(extra))
|
||||
}
|
||||
|
||||
/// Check an entry's bytes.
|
||||
pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
|
||||
if entry.method != 0 {
|
||||
return Err(format!("{}: compressed entries are not supported (not a LightCraft backup?)", entry.name));
|
||||
}
|
||||
if data.len() as u64 != entry.size || crc32fast::hash(data) != entry.crc {
|
||||
return Err(format!("{}: damaged (checksum mismatch)", entry.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A name for the restored library's folder.
|
||||
pub fn restored_dir_name(now_ms: f64) -> String {
|
||||
format!("library-restored-{}", now_ms.max(0.0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build(files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut w = ZipWriter::default();
|
||||
let mut out = Vec::new();
|
||||
for (n, d) in files {
|
||||
out.extend(w.entry(n, d).unwrap());
|
||||
out.extend_from_slice(d);
|
||||
}
|
||||
out.extend(w.finish().unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_backups_read_back() {
|
||||
let files: [(&str, &[u8]); 3] =
|
||||
[("README.txt", README.as_bytes()), ("library/catalog.log", b"{\"seq\":1}\n"), ("originals/abc123/IMG 1.jpg", &[0, 1, 2, 3, 255])];
|
||||
let zip = build(&files);
|
||||
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
|
||||
let (off, size) = find_central(tail).unwrap();
|
||||
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
for (e, (name, data)) in entries.iter().zip(files) {
|
||||
assert_eq!(e.name, name);
|
||||
let start = data_offset(e, &zip[e.header as usize..e.header as usize + 30]).unwrap() as usize;
|
||||
let got = &zip[start..start + e.size as usize];
|
||||
assert_eq!(got, data);
|
||||
verify(e, got).unwrap();
|
||||
}
|
||||
// damage is caught
|
||||
let e = &entries[2];
|
||||
assert!(verify(e, &[0, 1, 2, 3, 254]).is_err());
|
||||
assert!(find_central(b"not a zip at all, just some text").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keys_are_safe() {
|
||||
assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap"));
|
||||
assert_eq!(restore_key("library/../../x", "library-r"), None);
|
||||
assert_eq!(restore_key("library/evil.bin", "library-r"), None);
|
||||
assert_eq!(restore_key("originals/abc123/IMG.jpg", "d").as_deref(), Some("originals/abc123"));
|
||||
assert_eq!(restore_key("originals/../library/catalog.log", "d"), None);
|
||||
assert_eq!(restore_key("README.txt", "d"), None);
|
||||
assert_eq!(restore_key("thumbs/x.jpg", "d"), None);
|
||||
assert_eq!(original_entry("abc", "a/b\\c.jpg"), "originals/abc/a_b_c.jpg");
|
||||
assert_eq!(original_entry("abc", ".."), "originals/abc/original");
|
||||
assert!(valid_library_dir("library") && valid_library_dir("library-restored-17"));
|
||||
assert!(!valid_library_dir("library-../x") && !valid_library_dir("originals") && !valid_library_dir("library-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_limits_are_errors_not_panics() {
|
||||
let mut w = ZipWriter { offset: MAX_BYTES - 10, ..Default::default() };
|
||||
assert!(w.entry("x", &[0; 64]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ struct Mirror {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
/// Dirty files, least recently modified first.
|
||||
dirty: Vec<(String, Dirty)>,
|
||||
/// Why the last flush to browser storage failed (quota, eviction…), until one succeeds.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
@@ -138,6 +140,18 @@ impl Files {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the outcome of a flush to browser storage: `Some(why)` when it failed, `None` once
|
||||
/// one succeeds. While a flush is failing, the catalog [`Store`] refuses new writes, so the
|
||||
/// engine keeps the changes queued and reports them unsaved (`library.info` → `unsavedOps`,
|
||||
/// the top bar's warning) instead of pretending they were saved.
|
||||
pub fn set_error(&self, e: Option<String>) {
|
||||
self.lock().error = e;
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.lock().error.clone()
|
||||
}
|
||||
|
||||
/// A catalog [`Store`] over these files.
|
||||
pub fn store(&self) -> FilesStore {
|
||||
FilesStore(self.clone())
|
||||
@@ -147,19 +161,32 @@ impl Files {
|
||||
/// [`Store`] implementation over [`Files`].
|
||||
pub struct FilesStore(Files);
|
||||
|
||||
impl FilesStore {
|
||||
/// Writes fail while browser storage does (see [`Files::set_error`]).
|
||||
fn writable(&self) -> io::Result<()> {
|
||||
match self.0.error() {
|
||||
Some(e) => Err(io::Error::other(format!("browser storage: {e}"))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for FilesStore {
|
||||
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
Ok(self.0.get(name))
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.write(name, data);
|
||||
Ok(())
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.append(name, data);
|
||||
Ok(())
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
|
||||
self.writable()?;
|
||||
self.0.truncate(name, len as usize);
|
||||
Ok(())
|
||||
}
|
||||
@@ -224,6 +251,37 @@ mod tests {
|
||||
assert_eq!(names, ["catalog.snap", "catalog.log"]);
|
||||
}
|
||||
|
||||
/// A failing flush (quota exceeded, storage evicted) is not silent: commands report the change
|
||||
/// as unsaved and the engine keeps it queued; once storage works again it is written.
|
||||
#[test]
|
||||
fn flush_failures_show_as_unsaved_and_are_retried() {
|
||||
let mut disk = BTreeMap::new();
|
||||
let files = Files::default();
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
flush(&files, &mut disk);
|
||||
files.set_error(Some("QuotaExceededError".into()));
|
||||
let r = s.execute("photo.rate", &json!({"rating": 5}));
|
||||
assert!(matches!(r, Err(lightcraft_engine::EngineError::NotSaved(_))), "{r:?}");
|
||||
let (ops, why) = s.unsaved().unwrap();
|
||||
assert!(ops > 0 && why.contains("QuotaExceeded"), "{why}");
|
||||
files.set_error(None);
|
||||
s.persist_if_dirty();
|
||||
s.persist().unwrap();
|
||||
assert!(s.unsaved().is_none(), "written once storage works again");
|
||||
flush(&files, &mut disk);
|
||||
let id = s.selection.active.unwrap();
|
||||
let files2 = Files::default();
|
||||
for (k, v) in &disk {
|
||||
files2.preload(k, v.clone());
|
||||
}
|
||||
let mut s2 = Session::new();
|
||||
let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files2.store()), files: Box::new(files2.store()), on_disk: false };
|
||||
s2.open_library_in(stores, true).unwrap();
|
||||
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_survives_reload_through_flushes() {
|
||||
let mut disk = BTreeMap::new();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod backup;
|
||||
pub mod bench;
|
||||
pub mod files;
|
||||
pub mod store;
|
||||
@@ -24,6 +25,8 @@ pub mod wire;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod backend;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod safety;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod workers;
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Keeping a library in the browser safe: notices the user must see (a failed save, storage the
|
||||
//! browser may evict, a file that couldn't be stored), one tab at a time per library, a message
|
||||
//! instead of a dead page after a panic, and backup / restore of the whole library
|
||||
//! ([`crate::backup`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use js_sys::{Reflect, Uint8Array};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{
|
||||
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
|
||||
valid_hash, verify,
|
||||
};
|
||||
use crate::files::{Files, LIBRARY_FILES};
|
||||
use crate::store::storage_key;
|
||||
|
||||
thread_local! {
|
||||
/// Messages for the user, shown by the app on its next frame.
|
||||
static NOTICES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Queue a message for the user (shown for several seconds; also logged).
|
||||
pub fn notice(text: impl Into<String>) {
|
||||
let text = text.into();
|
||||
log::warn!("{text}");
|
||||
NOTICES.with(|n| n.borrow_mut().push(text));
|
||||
crate::web::request_repaint();
|
||||
}
|
||||
|
||||
/// Messages queued since the last call.
|
||||
pub fn take_notices() -> Vec<String> {
|
||||
NOTICES.with(|n| std::mem::take(&mut *n.borrow_mut()))
|
||||
}
|
||||
|
||||
fn document() -> Option<web_sys::Document> {
|
||||
web_sys::window()?.document()
|
||||
}
|
||||
|
||||
/// Ask the user (the browser's own confirm box). `false` when it can't be shown.
|
||||
pub fn confirm(text: &str) -> bool {
|
||||
web_sys::window().and_then(|w| w.confirm_with_message(text).ok()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cover the page with a message (the app can't continue, or must not start).
|
||||
pub fn show_blocking(text: &str) {
|
||||
let Some(doc) = document() else { return };
|
||||
let el = match doc.get_element_by_id("lightcraft_blocking") {
|
||||
Some(el) => el,
|
||||
None => {
|
||||
let Ok(el) = doc.create_element("div") else { return };
|
||||
el.set_id("lightcraft_blocking");
|
||||
let _ = el.set_attribute(
|
||||
"style",
|
||||
"position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;padding:32px;\
|
||||
background:#1c1c1c;color:#e0e0e0;font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center;white-space:pre-line",
|
||||
);
|
||||
if let Some(body) = doc.body() {
|
||||
let _ = body.append_child(&el);
|
||||
}
|
||||
el
|
||||
}
|
||||
};
|
||||
el.set_text_content(Some(text));
|
||||
if let Some(loading) = doc.get_element_by_id("lightcraft_loading") {
|
||||
loading.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/// `panic = "abort"` on wasm: a panic ends the module. Say so on the page instead of leaving a
|
||||
/// frozen canvas, and say that the stored library is intact.
|
||||
pub fn install_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let msg = match (info.payload().downcast_ref::<&str>(), info.payload().downcast_ref::<String>()) {
|
||||
(Some(s), _) => (*s).to_string(),
|
||||
(_, Some(s)) => s.clone(),
|
||||
_ => "unknown error".into(),
|
||||
};
|
||||
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
log::error!("LightCraft panicked{at}: {msg}");
|
||||
show_blocking(&format!(
|
||||
"LightCraft stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\
|
||||
If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error."
|
||||
));
|
||||
}));
|
||||
}
|
||||
|
||||
/// The name of the lock that keeps a library to one tab.
|
||||
const TAB_LOCK: &str = "lightcraft-library";
|
||||
|
||||
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks).
|
||||
pub async fn acquire_tab_lock() -> Option<bool> {
|
||||
let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?;
|
||||
let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?;
|
||||
let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?;
|
||||
let opts = js_sys::Object::new();
|
||||
Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?;
|
||||
let granted = js_sys::Promise::new(&mut |resolve, _reject| {
|
||||
let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue {
|
||||
let ours = !lock.is_null() && !lock.is_undefined();
|
||||
let _ = resolve.call1(&JsValue::NULL, &ours.into());
|
||||
// keep the lock until the page goes away: a promise that never settles
|
||||
if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL }
|
||||
});
|
||||
if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) {
|
||||
log::warn!("Web Locks request failed: {e:?}");
|
||||
}
|
||||
});
|
||||
JsFuture::from(granted).await.ok().map(|v| v.is_truthy())
|
||||
}
|
||||
|
||||
/// Offer a blob as a download named `name`.
|
||||
pub fn download_blob(name: &str, blob: &web_sys::Blob) -> Result<(), String> {
|
||||
let e = |e: JsValue| format!("download failed: {e:?}");
|
||||
let url = web_sys::Url::create_object_url_with_blob(blob).map_err(e)?;
|
||||
let doc = document().ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.click();
|
||||
// revoke once the download has started
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// File ▸ Back Up Library…: a zip of the library files (as the app has them, saved or not) and
|
||||
/// every original in storage, built entry by entry into the browser's memory (outside the wasm
|
||||
/// heap) and downloaded. `names`: content hash → the photo's file name.
|
||||
pub async fn backup(files: Files, backend: Backend, names: HashMap<String, String>) -> Result<(usize, f64), String> {
|
||||
let parts = js_sys::Array::new();
|
||||
let mut zip = ZipWriter::default();
|
||||
let mut bytes = 0f64;
|
||||
let mut add = |zip: &mut ZipWriter, name: &str, data: &[u8]| -> Result<(), String> {
|
||||
let header = zip.entry(name, data)?;
|
||||
parts.push(&Uint8Array::from(&header[..]));
|
||||
parts.push(&Uint8Array::from(data));
|
||||
bytes += (header.len() + data.len()) as f64;
|
||||
Ok(())
|
||||
};
|
||||
add(&mut zip, "README.txt", README.as_bytes())?;
|
||||
for name in LIBRARY_FILES {
|
||||
if let Some(d) = files.get(name) {
|
||||
add(&mut zip, &format!("library/{name}"), &d)?;
|
||||
}
|
||||
}
|
||||
let mut originals = 0;
|
||||
for hash in backend.list("originals").await? {
|
||||
if !valid_hash(&hash) {
|
||||
continue;
|
||||
}
|
||||
match backend.read(&storage_key(&hash)).await {
|
||||
Ok(Some(data)) => {
|
||||
let name = names.get(&hash).map_or("original", String::as_str);
|
||||
add(&mut zip, &original_entry(&hash, name), &data)?;
|
||||
originals += 1;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => return Err(format!("reading an original from browser storage: {e}")),
|
||||
}
|
||||
}
|
||||
let tail = zip.finish()?;
|
||||
parts.push(&Uint8Array::from(&tail[..]));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/zip");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(|e| format!("{e:?}"))?;
|
||||
let day: String = String::from(js_sys::Date::new_0().to_iso_string()).chars().take(10).collect();
|
||||
download_blob(&format!("lightcraft-library-backup-{day}.zip"), &blob)?;
|
||||
Ok((originals, bytes + tail.len() as f64))
|
||||
}
|
||||
|
||||
/// Bytes `start..end` of a picked file.
|
||||
async fn read_range(file: &web_sys::File, start: u64, end: u64) -> Result<Vec<u8>, String> {
|
||||
let blob = file.slice_with_f64_and_f64(start as f64, end as f64).map_err(|e| format!("{e:?}"))?;
|
||||
let buf = JsFuture::from(blob.array_buffer()).await.map_err(|e| format!("{e:?}"))?;
|
||||
Ok(Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
/// Restore a backup into a new library folder (nothing in storage is deleted or overwritten),
|
||||
/// then point [`ACTIVE_LIBRARY`] at it. Returns the folder's name; the caller reloads the page.
|
||||
pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, String> {
|
||||
let len = file.size() as u64;
|
||||
let tail = read_range(&file, len - tail_len(len), len).await?;
|
||||
let (cd_offset, cd_size) = find_central(&tail)?;
|
||||
let cd = read_range(&file, cd_offset, cd_offset + cd_size).await?;
|
||||
let entries = parse_central(&cd)?;
|
||||
if !entries.iter().any(|e| e.name == "library/catalog.snap" || e.name == "library/catalog.log") {
|
||||
return Err("this isn't a LightCraft library backup (no library/catalog files)".into());
|
||||
}
|
||||
let dir = restored_dir_name(js_sys::Date::now());
|
||||
let mut written = 0usize;
|
||||
for e in &entries {
|
||||
let Some(key) = restore_key(&e.name, &dir) else { continue };
|
||||
// originals are named by their content: one already stored is the same file
|
||||
if key.starts_with("originals/") && backend.exists(&key).await? {
|
||||
continue;
|
||||
}
|
||||
let local = read_range(&file, e.header, e.header + 30).await?;
|
||||
let start = data_offset(e, &local)?;
|
||||
let data = read_range(&file, start, start + e.size).await?;
|
||||
verify(e, &data)?;
|
||||
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
|
||||
written += 1;
|
||||
}
|
||||
backend.write(ACTIVE_LIBRARY, dir.as_bytes()).await?;
|
||||
log::info!("lightcraft: restored {written} files into {dir}");
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// File ▸ Restore Library from Backup…: confirm, pick the zip, restore, reload.
|
||||
pub fn pick_and_restore(backend: Backend, before_reload: impl FnOnce() + 'static) {
|
||||
let ok = confirm(
|
||||
"Restore a LightCraft library backup?\n\nThe backup becomes the library in this browser and the page reloads. \
|
||||
The current library is not deleted: it stays in browser storage.",
|
||||
);
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
let Some(doc) = document() else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_accept(".zip,application/zip");
|
||||
let inp = input.clone();
|
||||
let mut before_reload = Some(before_reload);
|
||||
let on_change = Closure::<dyn FnMut()>::new(move || {
|
||||
let Some(file) = inp.files().and_then(|f| f.get(0)) else { return };
|
||||
let backend = backend.clone();
|
||||
let before = before_reload.take();
|
||||
notice(format!("Restoring {}…", file.name()));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match restore(file, backend).await {
|
||||
Ok(_) => {
|
||||
if let Some(f) = before {
|
||||
f();
|
||||
}
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.location().reload();
|
||||
}
|
||||
}
|
||||
Err(e) => notice(format!("Restore failed: {e}. Nothing was changed: the current library is still in use.")),
|
||||
}
|
||||
});
|
||||
});
|
||||
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||
on_change.forget();
|
||||
input.click();
|
||||
}
|
||||
+154
-23
@@ -15,16 +15,21 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{ACTIVE_LIBRARY, DEFAULT_LIBRARY_DIR, valid_library_dir};
|
||||
use crate::bench::Bench;
|
||||
use crate::files::{Files, FlushOp, LIBRARY_FILES};
|
||||
use crate::safety::{self, notice};
|
||||
use crate::store::{Originals, content_hash, download_name, storage_key};
|
||||
use crate::wire::ThumbIndex;
|
||||
use crate::workers::{THUMB_INDEX, Workers};
|
||||
|
||||
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
|
||||
|
||||
/// Where the library files live in browser storage.
|
||||
const LIBRARY_DIR: &str = "library";
|
||||
/// After a failed save, wait this long before trying again (ms).
|
||||
const SAVE_RETRY_MS: f64 = 2000.0;
|
||||
|
||||
/// How long a notice stays on screen (s).
|
||||
const NOTICE_SECS: f64 = 8.0;
|
||||
|
||||
/// How often view state, UI prefs and the thumbnail index are saved (ms).
|
||||
const SAVE_EVERY_MS: f64 = 1000.0;
|
||||
@@ -45,8 +50,18 @@ fn set_status(text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn library_key(name: &str) -> String {
|
||||
format!("{LIBRARY_DIR}/{name}")
|
||||
/// Storage key of library file `name` in library folder `dir`.
|
||||
fn library_key(dir: &str, name: &str) -> String {
|
||||
format!("{dir}/{name}")
|
||||
}
|
||||
|
||||
/// Repaint the app (notices from async tasks).
|
||||
pub(crate) fn request_repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(c) = c.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// URL options (`?bench&store=idb&workers=2&reset`).
|
||||
@@ -86,8 +101,9 @@ async fn store_file(originals: Originals, backend: Option<Backend>, name: String
|
||||
if let Some(b) = &backend
|
||||
&& let Err(e) = b.write(&storage_key(&hash), &bytes).await
|
||||
{
|
||||
// still importable for this session; it won't survive a reload
|
||||
log::error!("storing {name} in browser storage failed: {e}");
|
||||
// a photo whose bytes aren't stored would be gone after a reload: don't add it
|
||||
notice(format!("{name} was not added: browser storage refused it ({e}). Free up space or back up and clear old photos."));
|
||||
return;
|
||||
}
|
||||
originals.added(&name, &hash, Arc::from(bytes));
|
||||
ctx.request_repaint();
|
||||
@@ -148,8 +164,42 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context) -> Services {
|
||||
fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen: Rc<Cell<bool>>, ctx: egui::Context) -> Services {
|
||||
let (backup_backend, backup_files) = (backend.clone(), files);
|
||||
let restore_backend = backend.clone();
|
||||
Services {
|
||||
backup_library: Some(Box::new(move |session: &mut Session| {
|
||||
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
// the photos' own names for the originals in the zip
|
||||
let names: std::collections::HashMap<String, String> = session
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
Source::File { path } => crate::store::hash_of_path(path).map(|h| (h.to_string(), p.file_name.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let files = backup_files.clone();
|
||||
notice("Backing up the library… (the download starts when it's ready)");
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match safety::backup(files, b, names).await {
|
||||
Ok((n, bytes)) => notice(format!(
|
||||
"Library backed up: {n} original{} ({:.1} MB). Keep the file somewhere safe.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
bytes / 1e6
|
||||
)),
|
||||
Err(e) => notice(format!("Backup failed: {e}")),
|
||||
}
|
||||
});
|
||||
Ok(json!({"started": true}))
|
||||
})),
|
||||
restore_library: Some(Box::new(move |_session: &mut Session| {
|
||||
let Some(b) = restore_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
let frozen = frozen.clone();
|
||||
// once the backup is in place, stop saving the old library (the page reloads)
|
||||
safety::pick_and_restore(b, move || frozen.set(true));
|
||||
Ok(json!({"started": true}))
|
||||
})),
|
||||
pick_files: Some(Box::new(move || {
|
||||
open_picker(originals.clone(), backend.clone(), ctx.clone());
|
||||
Vec::new() // files arrive asynchronously and are imported on a later frame
|
||||
@@ -176,21 +226,29 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write dirty library files to storage, in order, until none are left.
|
||||
async fn flush(files: Files, backend: Backend, flushing: Rc<Cell<bool>>) {
|
||||
/// Write dirty library files to storage, in order, until none are left. A failure (quota,
|
||||
/// storage cleared) is recorded in `files` — the catalog then reports changes as unsaved instead
|
||||
/// of accepting them silently — and retried after [`SAVE_RETRY_MS`].
|
||||
async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bool>>, failed_at: Rc<Cell<f64>>) {
|
||||
'outer: loop {
|
||||
let ops = files.take_dirty();
|
||||
if ops.is_empty() {
|
||||
if files.error().is_some() {
|
||||
log::info!("lightcraft: saving works again");
|
||||
}
|
||||
files.set_error(None);
|
||||
break;
|
||||
}
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let r = match op {
|
||||
FlushOp::Write { name, data } => backend.write(&library_key(name), data).await,
|
||||
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(name), Some(*offset), data).await,
|
||||
FlushOp::Write { name, data } => backend.write(&library_key(&dir, name), data).await,
|
||||
FlushOp::Append { name, offset, data } => backend.write_at(&library_key(&dir, name), Some(*offset), data).await,
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::error!("saving {} failed: {e}", op.name());
|
||||
files.failed(&ops[i..]);
|
||||
files.set_error(Some(e));
|
||||
failed_at.set(perf_now());
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
@@ -204,6 +262,8 @@ struct Boot {
|
||||
backend: Option<Backend>,
|
||||
files: Files,
|
||||
index: ThumbIndex,
|
||||
/// The library folder in storage (`library`, or a restored backup's).
|
||||
lib_dir: String,
|
||||
}
|
||||
|
||||
async fn boot(opts: Options) -> Boot {
|
||||
@@ -221,15 +281,32 @@ async fn boot(opts: Options) -> Boot {
|
||||
};
|
||||
let files = Files::default();
|
||||
let mut index = ThumbIndex::default();
|
||||
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
|
||||
if let Some(b) = &backend {
|
||||
if opts.reset {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
// everything this site keeps in the browser, photos included: never without asking
|
||||
let ok = safety::confirm(
|
||||
"?reset deletes the LightCraft library stored in this browser, including every imported photo. \
|
||||
This can't be undone. Delete it?",
|
||||
);
|
||||
if ok {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
}
|
||||
} else {
|
||||
log::info!("lightcraft: ?reset declined; storage kept");
|
||||
}
|
||||
}
|
||||
// a restored backup lives in its own folder (see `backup`)
|
||||
if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
|
||||
let d = String::from_utf8_lossy(&d).trim().to_string();
|
||||
if valid_library_dir(&d) {
|
||||
lib_dir = d;
|
||||
}
|
||||
}
|
||||
for name in LIBRARY_FILES {
|
||||
match b.read(&library_key(name)).await {
|
||||
match b.read(&library_key(&lib_dir, name)).await {
|
||||
Ok(Some(data)) => files.preload(name, data),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::error!("reading {name}: {e}"),
|
||||
@@ -238,7 +315,14 @@ async fn boot(opts: Options) -> Boot {
|
||||
if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
|
||||
index = ThumbIndex::from_json(&j);
|
||||
}
|
||||
crate::backend::request_persistence();
|
||||
crate::backend::request_persistence(|granted| {
|
||||
if !granted {
|
||||
notice(
|
||||
"This browser may delete LightCraft's library when it runs short of space (persistent storage wasn't granted). \
|
||||
Keep your original photos elsewhere and use File ▸ Back Up Library… now and then.",
|
||||
);
|
||||
}
|
||||
});
|
||||
// thumbnails stored without an index entry (index saved late, or lost): delete them
|
||||
let b2 = b.clone();
|
||||
let known: std::collections::HashSet<String> = b
|
||||
@@ -266,7 +350,7 @@ async fn boot(opts: Options) -> Boot {
|
||||
index.len(),
|
||||
index.total() as f64 / 1e6
|
||||
);
|
||||
Boot { opts, backend, files, index }
|
||||
Boot { opts, backend, files, index, lib_dir }
|
||||
}
|
||||
|
||||
struct WebApp {
|
||||
@@ -276,6 +360,11 @@ struct WebApp {
|
||||
backend: Option<Backend>,
|
||||
workers: Option<Workers>,
|
||||
flushing: Rc<Cell<bool>>,
|
||||
/// When the last save to storage failed (ms; 0 = never).
|
||||
flush_failed_at: Rc<Cell<f64>>,
|
||||
/// Saving stopped (a restored backup is about to replace this library: the page reloads).
|
||||
frozen: Rc<Cell<bool>>,
|
||||
lib_dir: String,
|
||||
bench: Option<Bench>,
|
||||
first_frame_logged: bool,
|
||||
last_save: f64,
|
||||
@@ -284,17 +373,18 @@ struct WebApp {
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
|
||||
let Boot { opts, backend, files, index } = boot;
|
||||
let Boot { opts, backend, files, index, lib_dir } = boot;
|
||||
let originals = Originals::default();
|
||||
let t = perf_now();
|
||||
let mut session = Session::new();
|
||||
originals.install(&mut session);
|
||||
let stores = LibraryStores {
|
||||
dir: format!("browser:{}/{LIBRARY_DIR}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
|
||||
dir: format!("browser:{}/{lib_dir}", backend.as_ref().map_or("memory", |b| b.kind())).into(),
|
||||
catalog: Box::new(files.store()),
|
||||
files: Box::new(files.store()),
|
||||
on_disk: false,
|
||||
};
|
||||
let mut problem = None;
|
||||
match session.open_library_in(stores, true).cloned() {
|
||||
Ok(r) => log::info!(
|
||||
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
|
||||
@@ -305,19 +395,35 @@ impl WebApp {
|
||||
r.replayed
|
||||
),
|
||||
Err(e) => {
|
||||
log::error!("opening the library failed: {e}; starting a temporary one");
|
||||
session = Session::with_demo();
|
||||
// never a silent demo session (issue #100): an empty one, and the window says why
|
||||
log::error!("opening the library failed: {e}");
|
||||
session = Session::new();
|
||||
originals.install(&mut session);
|
||||
problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem {
|
||||
can_retry: false,
|
||||
..lightcraft_ui_egui::panels::library_problem::LibraryProblem::new(
|
||||
"the browser's storage for this page",
|
||||
format!("{e}. Reload the page to try again."),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
if backend.is_some() {
|
||||
notice(
|
||||
"LightCraft in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \
|
||||
elsewhere and back up with File ▸ Back Up Library….",
|
||||
);
|
||||
}
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), cc.egui_ctx.clone()));
|
||||
let frozen = Rc::new(Cell::new(false));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
|
||||
let ui_written = files.get("ui.json").unwrap_or_default();
|
||||
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.ui = app.ui.sanitized();
|
||||
app.library_problem = problem;
|
||||
let n = opts.workers.unwrap_or_else(|| {
|
||||
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
@@ -337,6 +443,9 @@ impl WebApp {
|
||||
backend,
|
||||
workers,
|
||||
flushing: Rc::new(Cell::new(false)),
|
||||
flush_failed_at: Rc::new(Cell::new(0.0)),
|
||||
frozen,
|
||||
lib_dir,
|
||||
bench: opts.bench.then(|| Bench::new(origin)),
|
||||
first_frame_logged: false,
|
||||
last_save: 0.0,
|
||||
@@ -398,6 +507,9 @@ impl WebApp {
|
||||
|
||||
/// Save view state, UI prefs and the thumbnail index now and then; flush dirty files.
|
||||
fn save(&mut self) {
|
||||
if self.frozen.get() {
|
||||
return;
|
||||
}
|
||||
let now = perf_now();
|
||||
if now - self.last_save >= SAVE_EVERY_MS {
|
||||
self.last_save = now;
|
||||
@@ -419,12 +531,16 @@ impl WebApp {
|
||||
});
|
||||
}
|
||||
}
|
||||
// after a failure, retry every couple of seconds rather than every frame
|
||||
let backing_off = self.files.error().is_some() && now - self.flush_failed_at.get() < SAVE_RETRY_MS;
|
||||
if let Some(b) = &self.backend
|
||||
&& self.files.is_dirty()
|
||||
&& !self.flushing.get()
|
||||
&& !backing_off
|
||||
{
|
||||
self.flushing.set(true);
|
||||
wasm_bindgen_futures::spawn_local(flush(self.files.clone(), b.clone(), self.flushing.clone()));
|
||||
let (files, dir, flushing, failed_at) = (self.files.clone(), self.lib_dir.clone(), self.flushing.clone(), self.flush_failed_at.clone());
|
||||
wasm_bindgen_futures::spawn_local(flush(files, b.clone(), dir, flushing, failed_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,8 +570,10 @@ impl WebApp {
|
||||
let (orig_n, orig_bytes) = self.originals.usage();
|
||||
json!({
|
||||
"storage": self.backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
"library": self.lib_dir,
|
||||
"dirty": self.files.is_dirty(),
|
||||
"flushing": self.flushing.get(),
|
||||
"saveError": self.files.error(),
|
||||
"workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline},
|
||||
"originalsInMemory": orig_n,
|
||||
"originalBytesInMemory": orig_bytes,
|
||||
@@ -480,6 +598,10 @@ impl WebApp {
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for text in safety::take_notices() {
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.app.ui.toast = Some((text, now + NOTICE_SECS));
|
||||
}
|
||||
self.run_inbox();
|
||||
self.import_dropped(ctx);
|
||||
self.load_originals(ctx);
|
||||
@@ -517,9 +639,18 @@ impl eframe::App for WebApp {
|
||||
#[wasm_bindgen]
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
safety::install_panic_hook();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// one tab per library: two would each keep their own copy and overwrite each other's saves
|
||||
if opts.store != "memory" && safety::acquire_tab_lock().await == Some(false) {
|
||||
safety::show_blocking(
|
||||
"LightCraft is already open in another tab or window of this browser.\n\n\
|
||||
Switch to that tab, or close it and reload this page. (Two tabs would overwrite each other's changes.)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(canvas) = window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
|
||||
|
||||
@@ -15,10 +15,9 @@ use std::sync::Arc;
|
||||
|
||||
use lightcraft_engine::catalog::Source;
|
||||
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
|
||||
use lightcraft_engine::media::{MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef};
|
||||
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||
use lightcraft_preview::Lru;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::hash_of_path;
|
||||
@@ -31,6 +30,8 @@ pub struct WireJob {
|
||||
/// Decode the source at most this long.
|
||||
pub max_edge: usize,
|
||||
pub raw: bool,
|
||||
#[serde(default)]
|
||||
pub relative_wb: bool,
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
@@ -55,7 +56,7 @@ impl WireJob {
|
||||
pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob {
|
||||
let max_edge = match &job.source {
|
||||
SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge,
|
||||
SourceRef::Loaded(img) => img.width.max(img.height),
|
||||
SourceRef::Loaded(img) => img.image.width.max(img.image.height),
|
||||
SourceRef::Smart { .. } => 2560,
|
||||
};
|
||||
WireJob {
|
||||
@@ -63,6 +64,7 @@ impl WireJob {
|
||||
origin: job.origin.clone(),
|
||||
max_edge,
|
||||
raw: job.info.raw,
|
||||
relative_wb: job.info.relative_wb,
|
||||
as_shot_temp: job.info.as_shot_temp,
|
||||
as_shot_tint: job.info.as_shot_tint,
|
||||
lens: job.info.lens,
|
||||
@@ -79,7 +81,14 @@ impl WireJob {
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SourceInfo {
|
||||
SourceInfo { raw: self.raw, as_shot_temp: self.as_shot_temp, as_shot_tint: self.as_shot_tint, lens: self.lens }
|
||||
SourceInfo {
|
||||
raw: self.raw,
|
||||
as_shot_temp: self.as_shot_temp,
|
||||
as_shot_tint: self.as_shot_tint,
|
||||
lens: self.lens,
|
||||
relative_wb: self.relative_wb,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(&self) -> RenderRequest {
|
||||
@@ -119,7 +128,7 @@ const WORKER_SOURCE_BYTES: usize = 192 << 20;
|
||||
|
||||
/// The synchronous part of a worker: decoded-source cache + rendering.
|
||||
pub struct WorkerCore {
|
||||
sources: Lru<String, Arc<Rgb32f>>,
|
||||
sources: Lru<String, DecodedSource>,
|
||||
media: MediaCache,
|
||||
stages: HashMap<String, Arc<StageCache>>,
|
||||
}
|
||||
@@ -150,21 +159,25 @@ impl WorkerCore {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let src = match (&job.origin, original) {
|
||||
(Source::File { .. }, Some(bytes)) => Arc::new(lightcraft_engine::files::load_bytes(bytes, job.max_edge)?.0),
|
||||
(Source::File { .. }, Some(bytes)) => {
|
||||
let (image, info) = lightcraft_engine::files::load_bytes(bytes, job.max_edge)?;
|
||||
DecodedSource::new(Arc::new(image), Some(info))
|
||||
}
|
||||
(Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")),
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load()?,
|
||||
(origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load_source()?,
|
||||
};
|
||||
let cost = src.width * src.height * 12 + 64;
|
||||
let cost = src.image.width * src.image.height * 12 + 64;
|
||||
self.sources.insert(key, src.clone(), cost);
|
||||
src
|
||||
}
|
||||
};
|
||||
let info = src.info_or(job.info());
|
||||
Ok(match &job.stages {
|
||||
Some(view) => {
|
||||
let st = self.stages.entry(view.clone()).or_default().clone();
|
||||
lightcraft_engine::pipeline::render_cached(&src, &job.info(), &job.settings, &job.request(), &st)
|
||||
lightcraft_engine::pipeline::render_cached(&src.image, &info, &job.settings, &job.request(), &st)
|
||||
}
|
||||
None => lightcraft_engine::pipeline::render(&src, &job.info(), &job.settings, &job.request()),
|
||||
None => lightcraft_engine::pipeline::render(&src.image, &info, &job.settings, &job.request()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,6 +308,7 @@ mod tests {
|
||||
origin,
|
||||
max_edge: 512,
|
||||
raw: false,
|
||||
relative_wb: false,
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`lightcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,235 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the connection
|
||||
// closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7980\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"folder.move\"}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty());
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused_without_buffering_it_all() {
|
||||
// An endless line without a newline: reading stops after MAX_LINE + 1 bytes.
|
||||
let endless = std::io::repeat(b'x');
|
||||
let mut out = Vec::new();
|
||||
let mut ran = false;
|
||||
serve_lines(endless, &mut out, |_, _| {
|
||||
ran = true;
|
||||
None
|
||||
});
|
||||
assert!(!ran);
|
||||
let reply: Value = serde_json::from_slice(out.strip_suffix(b"\n").unwrap()).unwrap();
|
||||
assert!(reply["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
}
|
||||
+218
-40
@@ -7,7 +7,9 @@
|
||||
//! `~/Pictures/LightCraft 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).
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
|
||||
//! that can't be opened is never replaced silently: the window says why and asks what to do
|
||||
//! (`lightcraft_ui_egui::panels::library_problem`).
|
||||
//!
|
||||
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
@@ -26,6 +28,7 @@ mod control_server;
|
||||
mod native_menu;
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
|
||||
|
||||
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
|
||||
@@ -41,15 +44,16 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
struct App(LightcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(m) = self.1.as_mut() {
|
||||
if let Some(m) = self.2.as_mut() {
|
||||
m.update(&mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
@@ -58,7 +62,9 @@ impl eframe::App for App {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
eprintln!("lightcraft: {e}");
|
||||
}
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
eprintln!("lightcraft: saving the library failed: {e}");
|
||||
}
|
||||
@@ -78,30 +84,127 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any.
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
|
||||
/// when the file exists but can't be used (issue #103): a damaged file is kept as
|
||||
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
|
||||
/// one that can't be read at all is not written this session (`keep_file`).
|
||||
fn load_prefs() -> (Option<UiState>, Option<String>, bool) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
return (None, None, false);
|
||||
}
|
||||
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
|
||||
serde_json::from_slice::<UiState>(&bytes).ok().map(UiState::sanitized)
|
||||
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
|
||||
load_prefs_at(&path)
|
||||
}
|
||||
|
||||
fn save_prefs(app: &LightcraftApp) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
|
||||
Err(e) => {
|
||||
let msg = format!("The app settings ({}) couldn't be read: {e}. Defaults are used, and the file isn't overwritten.", path.display());
|
||||
return (None, Some(msg), true);
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
Err(e) => {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
|
||||
let kept = std::fs::rename(path, &keep);
|
||||
let what = match &kept {
|
||||
Ok(()) => format!("it was kept as {}", keep.display()),
|
||||
Err(r) => format!("it couldn't be set aside ({r})"),
|
||||
};
|
||||
let msg = format!(
|
||||
"The app settings ({}) are damaged ({e}); {what}. Defaults are used — reopen your library with Settings → Open Library… if it isn't shown.",
|
||||
path.display()
|
||||
);
|
||||
(None, Some(msg), kept.is_err())
|
||||
}
|
||||
}
|
||||
if let Some(d) = config_dir() {
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
|
||||
let _ = std::fs::write(d.join("ui.json"), bytes);
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` atomically: a temp file, fsynced, renamed over it (a crash or a full
|
||||
/// disk leaves the old file or the new one, never a truncated one).
|
||||
fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let written = (|| {
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
})();
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(dir) = path.parent()
|
||||
&& let Ok(d) = std::fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
|
||||
/// changes, every few seconds when anything else changed, and at exit.
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
checked: f64,
|
||||
/// The last write failed (reported once until a write works).
|
||||
failing: bool,
|
||||
/// `ui.json` couldn't be read at launch: never overwrite it this session.
|
||||
keep_file: bool,
|
||||
}
|
||||
|
||||
impl PrefsWriter {
|
||||
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
||||
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(d) = config_dir() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&d)
|
||||
.and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
|
||||
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
||||
self.written = bytes;
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let moved = app.ui.settings.library_path != self.library;
|
||||
if !moved && now - self.checked < 3.0 {
|
||||
return;
|
||||
}
|
||||
self.checked = now;
|
||||
match self.save(app) {
|
||||
Ok(()) => self.failing = false,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: {e}");
|
||||
if !self.failing {
|
||||
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
||||
}
|
||||
self.failing = true;
|
||||
// don't retry every frame
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +256,7 @@ fn services() -> Services {
|
||||
pick_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
"Photos",
|
||||
lightcraft_ui_egui::i18n::tr("Photos"),
|
||||
&[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
|
||||
"jxl", "gif", "bmp",
|
||||
@@ -167,8 +270,11 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Presets")
|
||||
.add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
|
||||
.add_filter(
|
||||
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
|
||||
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
|
||||
)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -177,24 +283,24 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Auto-Tag from Tracklog")
|
||||
.add_filter("GPS Track Log", &["gpx"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Presets")
|
||||
.add_filter("LightCraft Preset", &["lcpreset"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve", "json"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -203,8 +309,8 @@ fn services() -> Services {
|
||||
})),
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve"])
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
|
||||
.add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -215,18 +321,25 @@ fn services() -> Services {
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
// the library is a folder on disk: backed up with the user's other files
|
||||
backup_library: None,
|
||||
restore_library: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent library session (or an in-memory one with `--memory` / if the library can't open).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> Session {
|
||||
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock();
|
||||
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
||||
///
|
||||
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
||||
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
||||
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
if in_memory {
|
||||
return fallback();
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY); running in memory");
|
||||
return fallback();
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = Session::new().with_fs().with_system_clock();
|
||||
@@ -240,11 +353,11 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
if torn > 0 { ", torn tail repaired" } else { "" },
|
||||
t0.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
s
|
||||
(s, None)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: can't open library {}: {e}; running in memory", dir.display());
|
||||
fallback()
|
||||
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
|
||||
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,7 +406,7 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let prefs = load_prefs();
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -321,17 +434,30 @@ fn main() -> eframe::Result {
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let (session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.notices.extend(prefs_warning);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if !files.is_empty() {
|
||||
if let Some(mut p) = problem {
|
||||
// imported once the user has chosen where (into the library, or the temporary session)
|
||||
p.pending_import = files;
|
||||
app.library_problem = Some(p);
|
||||
} else if !files.is_empty() {
|
||||
let _ = app.run("library.import", serde_json::json!({"paths": files}));
|
||||
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
@@ -341,9 +467,61 @@ fn main() -> eframe::Result {
|
||||
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
|
||||
/// reported, not silently replaced by the defaults.
|
||||
#[test]
|
||||
fn damaged_ui_json_is_kept_and_reported() {
|
||||
let d = dir("damaged");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path);
|
||||
assert!(ui.is_none() && !keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
||||
assert!(!path.exists());
|
||||
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
|
||||
// a missing file is just "no settings yet"
|
||||
assert_eq!(load_prefs_at(&path).1, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_json_is_written_atomically() {
|
||||
let d = dir("atomic");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
write_atomic(&path, b"{\"new\": true}").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
assert!(!d.join("ui.json.tmp").exists());
|
||||
// a failed write leaves the old file
|
||||
let missing = d.join("gone").join("ui.json");
|
||||
assert!(write_atomic(&missing, b"x").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
let ui = UiState::default();
|
||||
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
|
||||
let (loaded, warning, _) = load_prefs_at(&path);
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -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, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
|
||||
let about = MenuItem::with_id("app.about", lightcraft_ui_egui::i18n::tr("About LightCraft"), true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, lightcraft_ui_egui::i18n::tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, lightcraft_ui_egui::i18n::tr("Quit LightCraft"), true, accelerator("Cmd+Q"));
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&settings,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&PredefinedMenuItem::services(Some(lightcraft_ui_egui::i18n::tr("Services"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::hide(Some(lightcraft_ui_egui::i18n::tr("Hide LightCraft"))),
|
||||
&PredefinedMenuItem::hide_others(Some(lightcraft_ui_egui::i18n::tr("Hide Others"))),
|
||||
&PredefinedMenuItem::show_all(Some(lightcraft_ui_egui::i18n::tr("Show All"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&quit,
|
||||
]);
|
||||
let _ = self.menu.append(&app_menu);
|
||||
|
||||
for (title, nodes) in &bar {
|
||||
let sub = Submenu::new(title, true);
|
||||
if title == app.ui.language.tr("Window") {
|
||||
let sub = Submenu::new(label_text(title), true);
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(Some("Zoom")),
|
||||
&PredefinedMenuItem::minimize(Some(lightcraft_ui_egui::i18n::tr("Minimize"))),
|
||||
&PredefinedMenuItem::maximize(Some(lightcraft_ui_egui::i18n::tr("Zoom"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
]);
|
||||
}
|
||||
@@ -245,15 +245,21 @@ impl NativeMenu {
|
||||
.collect();
|
||||
let nodes = tidy_separators(nodes);
|
||||
self.append_nodes(&sub, &nodes);
|
||||
if title == app.ui.language.tr("File") {
|
||||
if title == "File" {
|
||||
// ⌘W, the system's own item
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]);
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::close_window(Some(lightcraft_ui_egui::i18n::tr("Close Window"))),
|
||||
]);
|
||||
}
|
||||
if title == app.ui.language.tr("Window") {
|
||||
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
|
||||
if title == "Window" {
|
||||
let _ = sub.append_items(&[
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(Some(lightcraft_ui_egui::i18n::tr("Bring All to Front"))),
|
||||
]);
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
}
|
||||
if title == app.ui.language.tr("Help") {
|
||||
if title == "Help" {
|
||||
sub.set_as_help_menu_for_nsapp();
|
||||
}
|
||||
let _ = self.menu.append(&sub);
|
||||
@@ -282,12 +288,23 @@ impl NativeMenu {
|
||||
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
|
||||
let handle = match checked {
|
||||
Some(c) => {
|
||||
let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel);
|
||||
let it = CheckMenuItem::with_id(
|
||||
key.clone(),
|
||||
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
|
||||
*enabled,
|
||||
*c,
|
||||
accel,
|
||||
);
|
||||
let _ = sub.append(&it);
|
||||
Handle::Check(it)
|
||||
}
|
||||
None => {
|
||||
let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel);
|
||||
let it = MenuItem::with_id(
|
||||
key.clone(),
|
||||
lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"),
|
||||
*enabled,
|
||||
accel,
|
||||
);
|
||||
let _ = sub.append(&it);
|
||||
Handle::Plain(it)
|
||||
}
|
||||
@@ -325,6 +342,7 @@ impl NativeMenu {
|
||||
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
|
||||
/// accelerators with the app state.
|
||||
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
lightcraft_ui_egui::i18n::set_language(app.ui.language);
|
||||
while let Ok(key) = self.rx.try_recv() {
|
||||
if key == QUIT {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -352,8 +370,8 @@ impl NativeMenu {
|
||||
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
|
||||
if it.label != *label {
|
||||
match &it.handle {
|
||||
Handle::Plain(h) => h.set_text(label_text(label)),
|
||||
Handle::Check(h) => h.set_text(label_text(label)),
|
||||
Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
|
||||
Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")),
|
||||
}
|
||||
it.label = label.clone();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
|
||||
| `assets/fonts/BIZUDPGothic-*.ttf` | BIZ UDPGothic (Regular, Bold) | Morisawa / BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic/tree/18934af56b9c003ca58c54bffbf226848cb11032 | SIL Open Font License 1.1 (`assets/fonts/OFL-BIZUDGothic.txt`) | 2026-10-05 | none |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
+126
-21
@@ -1,8 +1,9 @@
|
||||
//! Crash-safe catalog persistence: an append-only op log plus periodic snapshots.
|
||||
//!
|
||||
//! Files (in a [`Store`]):
|
||||
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the
|
||||
//! state after op `N`; replaced atomically (temp + fsync + rename).
|
||||
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":V,"seq":N,"catalog":{…}}`, the
|
||||
//! state after op `N` in catalog format `V` ([`VERSION`]); replaced atomically (temp + fsync +
|
||||
//! rename).
|
||||
//! - `catalog.log` — JSON lines, one per op applied after the snapshot:
|
||||
//! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are
|
||||
//! fsynced before [`Journal::append`] returns.
|
||||
@@ -33,6 +34,32 @@
|
||||
//!
|
||||
//! At most one snapshot is in flight; a synchronous [`Journal::snapshot`] and dropping the journal
|
||||
//! first wait for it, so an older snapshot can never replace a newer one.
|
||||
//!
|
||||
//! # Format versions
|
||||
//!
|
||||
//! The snapshot's `version` is the catalog format, and it covers the log beside it: the log has
|
||||
//! no header of its own, and is only ever appended to under a snapshot of this build's format.
|
||||
//!
|
||||
//! | Version | Written by | Adds |
|
||||
//! |---|---|---|
|
||||
//! | (none) | early builds: a log without a snapshot | |
|
||||
//! | 1 | up to v0.2.0 | |
|
||||
//! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
//! serialized field to the catalog, a photo, an album, a stack or develop settings that an older
|
||||
//! build would misread or silently drop. New fields must deserialize from older data
|
||||
//! (`#[serde(default)]`), so newer builds keep reading every older format.
|
||||
//! - **Opening an older format upgrades it**: right after loading, [`Journal::open`] writes a
|
||||
//! snapshot in this build's format (also for a new library), before anything is appended — so a
|
||||
//! library is never a mix, and older builds refuse it from then on instead of reading part of it.
|
||||
//! [`LoadReport::upgraded_from`] says when that happened.
|
||||
//! - **A newer format is refused, untouched**: a snapshot whose `version` is above [`VERSION`],
|
||||
//! or a log record whose CRC matches (so it is exactly what was written) but whose op doesn't
|
||||
//! parse, fails [`Journal::open`] with [`CatalogError::Newer`] before any file is modified — it
|
||||
//! is never mistaken for a torn tail or damage. (Builds up to v0.2.0 refuse a v2 snapshot with
|
||||
//! "unsupported format".)
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::{Catalog, CatalogError, Op, Result};
|
||||
@@ -40,7 +67,9 @@ use crate::{Catalog, CatalogError, Op, Result};
|
||||
pub const SNAPSHOT: &str = "catalog.snap";
|
||||
pub const LOG: &str = "catalog.log";
|
||||
const FORMAT: &str = "lightcraft-catalog";
|
||||
const VERSION: u32 = 1;
|
||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
||||
/// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.
|
||||
pub const VERSION: u32 = 2;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -75,6 +104,9 @@ pub struct LoadReport {
|
||||
pub damaged: Option<String>,
|
||||
/// No catalog files existed (a new library).
|
||||
pub created: bool,
|
||||
/// The library was in an older catalog format (`0` = before format versions: a log without a
|
||||
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
|
||||
pub upgraded_from: Option<u32>,
|
||||
}
|
||||
|
||||
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
|
||||
@@ -180,18 +212,46 @@ pub fn encode_record(seq: u64, op: &Op) -> String {
|
||||
format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}")
|
||||
}
|
||||
|
||||
/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match.
|
||||
/// Parse one log line (without the newline). `None` if it's malformed, the CRC doesn't match, or
|
||||
/// the op is one this version doesn't know (see [`Journal::open`]).
|
||||
pub fn decode_record(line: &str) -> Option<(u64, Op)> {
|
||||
let rest = line.strip_prefix("{\"seq\":")?;
|
||||
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
||||
let (crc, rest) = rest.split_once(",\"op\":")?;
|
||||
let body = rest.strip_suffix('}')?;
|
||||
let seq: u64 = seq.parse().ok()?;
|
||||
let crc: u32 = crc.parse().ok()?;
|
||||
if crc32fast::hash(body.as_bytes()) != crc {
|
||||
return None;
|
||||
match decode_line(line) {
|
||||
Line::Record(seq, op) => Some((seq, op)),
|
||||
Line::Newer(_) | Line::Bad => None,
|
||||
}
|
||||
serde_json::from_str(body).ok().map(|op| (seq, op))
|
||||
}
|
||||
|
||||
/// One log line, decoded.
|
||||
enum Line {
|
||||
Record(u64, Op),
|
||||
/// The CRC matches — the line is exactly what was written — but the op doesn't parse: it
|
||||
/// was written by a newer LightCraft (an op or field this version doesn't know), not damaged.
|
||||
Newer(u64),
|
||||
/// Malformed or a CRC mismatch: torn or damaged.
|
||||
Bad,
|
||||
}
|
||||
|
||||
fn decode_line(line: &str) -> Line {
|
||||
let parts = || {
|
||||
let rest = line.strip_prefix("{\"seq\":")?;
|
||||
let (seq, rest) = rest.split_once(",\"crc\":")?;
|
||||
let (crc, rest) = rest.split_once(",\"op\":")?;
|
||||
let body = rest.strip_suffix('}')?;
|
||||
Some((seq.parse::<u64>().ok()?, crc.parse::<u32>().ok()?, body))
|
||||
};
|
||||
let Some((seq, crc, body)) = parts() else { return Line::Bad };
|
||||
if crc32fast::hash(body.as_bytes()) != crc {
|
||||
return Line::Bad;
|
||||
}
|
||||
match serde_json::from_str(body) {
|
||||
Ok(op) => Line::Record(seq, op),
|
||||
Err(_) => Line::Newer(seq),
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for a library written by a newer LightCraft.
|
||||
fn newer(what: String) -> CatalogError {
|
||||
CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older"))
|
||||
}
|
||||
|
||||
/// A line that doesn't decode but ends with a whole record: a fragment of a failed append followed
|
||||
@@ -229,10 +289,16 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
|
||||
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Just the identification of `catalog.snap` (read before the catalog itself).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
struct SnapHeader {
|
||||
format: String,
|
||||
version: u32,
|
||||
}
|
||||
|
||||
/// The whole `catalog.snap` (after [`SnapHeader`] was checked; `format` and `version` are ignored).
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapFile {
|
||||
seq: u64,
|
||||
catalog: Catalog,
|
||||
}
|
||||
@@ -245,15 +311,20 @@ impl Journal {
|
||||
let snap = store.read(SNAPSHOT).map_err(io)?;
|
||||
let log = store.read(LOG).map_err(io)?;
|
||||
report.created = snap.is_none() && log.is_none();
|
||||
let (mut catalog, snapshot_seq) = match snap {
|
||||
let (mut catalog, snapshot_seq, snapshot_version) = match snap {
|
||||
Some(bytes) => {
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if s.format != FORMAT || s.version > VERSION {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version)));
|
||||
// the header first: a newer snapshot may not parse as this version's catalog
|
||||
let h: SnapHeader = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if h.format != FORMAT {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a LightCraft catalog (format {:?})", h.format)));
|
||||
}
|
||||
(s.catalog, s.seq)
|
||||
if h.version > VERSION {
|
||||
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
|
||||
}
|
||||
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
(s.catalog, s.seq, Some(h.version))
|
||||
}
|
||||
None => (Catalog::new(), 0),
|
||||
None => (Catalog::new(), 0, None),
|
||||
};
|
||||
report.snapshot_seq = snapshot_seq;
|
||||
let mut j = Journal {
|
||||
@@ -296,6 +367,11 @@ impl Journal {
|
||||
report.torn_fragments += 1;
|
||||
record = Some(r);
|
||||
}
|
||||
if record.is_none()
|
||||
&& let Some(Line::Newer(seq)) = line.map(decode_line)
|
||||
{
|
||||
return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know")));
|
||||
}
|
||||
match record {
|
||||
Some((seq, op)) if seq <= j.seq => {
|
||||
// already in the snapshot
|
||||
@@ -312,7 +388,15 @@ impl Journal {
|
||||
}
|
||||
_ => {
|
||||
// bad record (or a gap): torn tail if nothing good follows, else damage
|
||||
let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some());
|
||||
let mut rest_has_good = false;
|
||||
for l in log[next..].split(|b| *b == b'\n') {
|
||||
match std::str::from_utf8(l).map(|l| decode_line(l.trim_end_matches('\r'))) {
|
||||
Ok(Line::Record(..)) => rest_has_good = true,
|
||||
// refused before anything is modified
|
||||
Ok(Line::Newer(seq)) => return Err(newer(format!("{LOG} record {seq} holds a change this version doesn't know"))),
|
||||
Ok(Line::Bad) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
if rest_has_good {
|
||||
damaged_at = Some(pos);
|
||||
}
|
||||
@@ -344,6 +428,27 @@ impl Journal {
|
||||
j.log_bytes = good_end as u64;
|
||||
}
|
||||
}
|
||||
// Bring an older (or new, or snapshot-less) library to this version's format before
|
||||
// anything is appended: a snapshot in this format makes older builds refuse the library
|
||||
// instead of misreading ops or dropping fields they don't know.
|
||||
if report.created {
|
||||
// tiny: written directly, not counted as a compaction
|
||||
let empty = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":0,\"catalog\":{}}}\n", catalog.to_snapshot());
|
||||
if let Err(e) = j.store.write_atomic(SNAPSHOT, empty.as_bytes()) {
|
||||
log::warn!("catalog: can't write the new library's snapshot: {e}");
|
||||
}
|
||||
} else if report.damaged.is_none() && snapshot_version != Some(VERSION) {
|
||||
report.upgraded_from = Some(snapshot_version.unwrap_or(0));
|
||||
match j.snapshot(&catalog) {
|
||||
Ok(()) => {
|
||||
if let Some(v) = report.upgraded_from {
|
||||
log::info!("catalog: upgraded from format v{v} to v{VERSION}");
|
||||
}
|
||||
}
|
||||
// nothing lost: the old files are intact and still load; retried on next open
|
||||
Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),
|
||||
}
|
||||
}
|
||||
catalog.revision = 0;
|
||||
Ok((j, catalog, report))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
|
||||
//! - **undo/redo**: the engine keeps inverse ops;
|
||||
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
|
||||
//!
|
||||
//! **Catalog format version** ([`journal::VERSION`], see [`journal`] → *Format versions*): adding
|
||||
//! an [`Op`] variant or a serialized field means bumping it. Newer builds read every older format
|
||||
//! (and upgrade it on open); older builds refuse a newer library with [`CatalogError::Newer`]
|
||||
//! instead of reading part of it.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -12,6 +17,7 @@ pub mod dates;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
pub mod lock;
|
||||
pub mod model;
|
||||
pub mod query;
|
||||
pub mod rules;
|
||||
@@ -27,6 +33,7 @@ pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTim
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of};
|
||||
pub use lock::{LibraryLock, LockError, LockOwner};
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
@@ -47,6 +54,10 @@ pub enum CatalogError {
|
||||
Corrupt(String),
|
||||
#[error("catalog storage: {0}")]
|
||||
Io(String),
|
||||
/// The library was written by a newer LightCraft (a newer catalog format, or a change this
|
||||
/// version doesn't know). Nothing was read into the session and nothing was modified.
|
||||
#[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")]
|
||||
Newer(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CatalogError>;
|
||||
@@ -644,8 +655,12 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
#[cfg(test)]
|
||||
mod tests_lock;
|
||||
#[cfg(test)]
|
||||
mod tests_torn_append;
|
||||
@@ -0,0 +1,140 @@
|
||||
//! One process per library: an exclusive OS lock on `<library>/catalog.lock`, held for as long as
|
||||
//! the library is open (issue #99).
|
||||
//!
|
||||
//! Two processes on the same library (the app and `lightcraft-cli --library`, two app instances,
|
||||
//! two computers on a shared folder) would each keep their own catalog and `seq` counter, and
|
||||
//! whichever wrote the last snapshot would silently drop the other's edits. So opening a library
|
||||
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
|
||||
//!
|
||||
//! - The lock is the OS's (`flock` on Unix, `LockFileEx` on Windows, via [`std::fs::File::try_lock`]):
|
||||
//! it is released when the [`LibraryLock`] is dropped and, after a crash or a kill, by the OS when
|
||||
//! the process ends. A `catalog.lock` file left behind is therefore never "stale": only a lock
|
||||
//! that's actually held refuses the open.
|
||||
//! - Who holds it (process id, computer, program, version) is written to `catalog.lock.owner`, for
|
||||
//! the error message. It's a separate file because a Windows lock also blocks reading the
|
||||
//! locked file. It's only a hint: it may be missing or left over from a crash.
|
||||
//! - File systems without lock support (some network shares): the library opens unlocked, with a
|
||||
//! warning — refusing it would lock the user out of their own library. Locks over SMB/NFS are
|
||||
//! advisory and depend on the server; the owner file is then the only hint.
|
||||
//! - Browser storage (OPFS) has no file locks: see the web host for its own guard.
|
||||
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The lock file in the library directory.
|
||||
pub const LOCK: &str = "catalog.lock";
|
||||
/// Who holds the lock (JSON, see [`LockOwner`]).
|
||||
pub const OWNER: &str = "catalog.lock.owner";
|
||||
|
||||
/// Who has the library open (as written in [`OWNER`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LockOwner {
|
||||
pub pid: u32,
|
||||
/// Computer name, if known.
|
||||
pub host: String,
|
||||
/// The program (`LightCraft`, `lightcraft-cli`…) and its version.
|
||||
pub program: String,
|
||||
pub version: String,
|
||||
/// When it opened the library (seconds since 1970).
|
||||
pub since: u64,
|
||||
}
|
||||
|
||||
impl LockOwner {
|
||||
/// This process, as `program`.
|
||||
pub fn this_process(program: &str) -> LockOwner {
|
||||
LockOwner {
|
||||
pid: std::process::id(),
|
||||
host: host_name(),
|
||||
program: program.to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
since: web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// "LightCraft 0.3.0 (process 123 on studio-mac)".
|
||||
pub fn describe(&self) -> String {
|
||||
let program = if self.program.is_empty() { "another LightCraft" } else { self.program.as_str() };
|
||||
let version = if self.version.is_empty() { String::new() } else { format!(" {}", self.version) };
|
||||
let host = if self.host.is_empty() {
|
||||
String::new()
|
||||
} else if self.host == host_name() {
|
||||
" on this computer".to_string()
|
||||
} else {
|
||||
format!(" on {}", self.host)
|
||||
};
|
||||
format!("{program}{version} (process {}{host})", self.pid)
|
||||
}
|
||||
}
|
||||
|
||||
/// The computer's name, best effort (empty if unknown).
|
||||
fn host_name() -> String {
|
||||
let env = ["COMPUTERNAME", "HOSTNAME"].iter().find_map(|k| std::env::var(k).ok().filter(|h| !h.trim().is_empty()));
|
||||
env.or_else(|| std::fs::read_to_string("/etc/hostname").ok()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LockError {
|
||||
/// Another process has the library open.
|
||||
#[error("This library is already open in {}. Close it there first.", .0.as_ref().map(LockOwner::describe).unwrap_or_else(|| "another LightCraft program".into()))]
|
||||
InUse(Option<LockOwner>),
|
||||
}
|
||||
|
||||
/// The exclusive lock on a library directory; released on drop (and by the OS if the process
|
||||
/// dies).
|
||||
#[derive(Debug)]
|
||||
pub struct LibraryLock {
|
||||
dir: PathBuf,
|
||||
/// `None` when the file system can't lock (the library is open unlocked).
|
||||
_file: Option<File>,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
impl LibraryLock {
|
||||
/// Lock `dir` (which must exist) for this process. Fails only when another process holds it.
|
||||
pub fn acquire(dir: &Path, program: &str) -> Result<LibraryLock, LockError> {
|
||||
let unlocked = |why: io::Error| {
|
||||
log::warn!("library {}: can't lock {LOCK} ({why}); opening it without protection against a second program", dir.display());
|
||||
LibraryLock { dir: dir.to_path_buf(), _file: None, held: false }
|
||||
};
|
||||
let file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(LOCK)) {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Ok(unlocked(e)),
|
||||
};
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
let owner = std::fs::read(dir.join(OWNER)).ok().and_then(|b| serde_json::from_slice::<LockOwner>(&b).ok());
|
||||
return Err(LockError::InUse(owner));
|
||||
}
|
||||
Err(TryLockError::Error(e)) => return Ok(unlocked(e)),
|
||||
}
|
||||
let owner = LockOwner::this_process(program);
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&owner)
|
||||
&& let Err(e) = std::fs::write(dir.join(OWNER), json)
|
||||
{
|
||||
log::warn!("library {}: can't write {OWNER}: {e}", dir.display());
|
||||
}
|
||||
Ok(LibraryLock { dir: dir.to_path_buf(), _file: Some(file), held: true })
|
||||
}
|
||||
|
||||
/// The OS lock is held (`false`: the file system can't lock; opened unprotected).
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LibraryLock {
|
||||
/// Remove the owner note while still holding the lock (so it can't delete the next holder's),
|
||||
/// then the file handle closes and the OS releases the lock.
|
||||
fn drop(&mut self) {
|
||||
if self.held {
|
||||
let _ = std::fs::remove_file(self.dir.join(OWNER));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,11 @@ impl Photo {
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW reader has vendor WB multipliers but no measured camera illuminant.
|
||||
/// Use adjustments relative to the camera's as-shot look, as for rendered photographs.
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw() && self.format.eq_ignore_ascii_case("ARW")
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
pub fn import_defaults(&self) -> DevelopSettings {
|
||||
@@ -342,7 +347,8 @@ impl Photo {
|
||||
/// The built-in defaults for this photo, before any user default preset: raws start from
|
||||
/// their as-shot white balance; embedded lens corrections on when the file has them.
|
||||
pub fn camera_defaults(&self) -> DevelopSettings {
|
||||
let mut d = match self.as_shot_wb {
|
||||
let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb };
|
||||
let mut d = match wb {
|
||||
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
|
||||
None => DevelopSettings::default(),
|
||||
};
|
||||
|
||||
@@ -180,7 +180,8 @@ fn failed_background_snapshot_loses_nothing_and_retries_later() {
|
||||
wait_written(&j);
|
||||
assert!(j.poll().is_err());
|
||||
assert_eq!((j.snapshot_seq(), j.stats().failed_snapshots), (0, 1));
|
||||
assert!(g.files.get(SNAPSHOT).is_none());
|
||||
// still the (empty) snapshot written when the library was created
|
||||
assert_eq!(g.snapshot_seq_on_disk(), Some(0));
|
||||
assert_recovers(&g, &live);
|
||||
// no retry storm: the next attempt waits for another `max_records` records
|
||||
assert!(!j.wants_snapshot());
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Issue #102: catalog format versions. Older formats load and are upgraded; a newer format is
|
||||
//! refused with a clear error and left untouched (never read in part, truncated or set aside).
|
||||
|
||||
use crate::journal::{LOG, SNAPSHOT, VERSION, encode_record};
|
||||
use crate::*;
|
||||
|
||||
fn photo(c: &mut Catalog) -> Op {
|
||||
let id = c.alloc_photo_id();
|
||||
Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, "p.jpg", "JPEG", 4, 3, "2026-01-01")) }
|
||||
}
|
||||
|
||||
/// A catalog with two photos and the log line for a third op on top of it.
|
||||
fn legacy_parts() -> (Catalog, String, Catalog) {
|
||||
let mut c = Catalog::new();
|
||||
for _ in 0..2 {
|
||||
let op = photo(&mut c);
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
let base = c.clone();
|
||||
let id = c.photos().next().unwrap().id;
|
||||
let op = Op::SetRating { id, rating: 4 };
|
||||
c.apply(op.clone()).unwrap();
|
||||
(base, format!("{}\n", encode_record(3, &op)), c)
|
||||
}
|
||||
|
||||
fn snapshot_version(m: &MemStore) -> u64 {
|
||||
let v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
|
||||
v["version"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_library_loads_and_is_upgraded() {
|
||||
let (base, log, full) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
let (mut j, c, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, Some(1)));
|
||||
// rewritten in this format before anything is appended
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
assert!(m.get(LOG).unwrap().is_empty());
|
||||
assert_eq!(j.seq(), 3);
|
||||
j.append(&[Op::SetLabelName { label: ColorLabel::ALL[0], name: Some("x".into()) }]).unwrap();
|
||||
drop(j);
|
||||
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert_eq!((r.replayed, r.upgraded_from), (1, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versionless_log_only_library_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
let op = photo(&mut c);
|
||||
c.apply(op.clone()).unwrap();
|
||||
let m = MemStore::new();
|
||||
m.set(LOG, format!("{}\n", encode_record(1, &op)).into_bytes());
|
||||
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert_eq!(loaded.to_snapshot(), c.to_snapshot());
|
||||
assert_eq!(r.upgraded_from, Some(0));
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
/// A new library gets a snapshot in this format at once, so an older build never sees a bare log
|
||||
/// it would read in part.
|
||||
#[test]
|
||||
fn new_library_is_written_in_this_format() {
|
||||
let m = MemStore::new();
|
||||
let (_, _, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
assert!(r.created);
|
||||
assert_eq!(r.upgraded_from, None);
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
fn assert_refused_untouched(m: &MemStore) {
|
||||
let before = m.files.lock().unwrap().clone();
|
||||
let e = Journal::open(Box::new(m.clone())).err().expect("refused");
|
||||
assert!(matches!(e, CatalogError::Newer(_)), "{e:?}");
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("newer version of LightCraft") && msg.contains("left unchanged"), "{msg}");
|
||||
assert_eq!(*m.files.lock().unwrap(), before, "nothing modified, nothing renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_snapshot_is_refused_untouched() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{}}}\n", VERSION + 1, base.to_snapshot()).into_bytes(),
|
||||
);
|
||||
m.set(LOG, log.into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
// even when its catalog doesn't parse as this version's
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":2,\"catalog\":{{\"photos\":7}}}}\n", VERSION + 1).into_bytes(),
|
||||
);
|
||||
assert_refused_untouched(&m);
|
||||
let e = Journal::open(Box::new(m)).err().unwrap().to_string();
|
||||
assert!(e.contains(&format!("v{}", VERSION + 1)), "{e}");
|
||||
}
|
||||
|
||||
/// A log record with a valid CRC whose op this version doesn't know: refused, not taken for a
|
||||
/// torn tail (truncated) or damage (renamed) — wherever it is in the log.
|
||||
#[test]
|
||||
fn unknown_op_in_the_log_is_refused_untouched() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let future = |seq: u64| {
|
||||
let body = r#"{"op":"fromTheFuture","id":1,"what":"something new"}"#;
|
||||
format!("{{\"seq\":{seq},\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes()))
|
||||
};
|
||||
let snap = format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot());
|
||||
let id = base.photos().next().unwrap().id;
|
||||
let after = format!("{}\n", encode_record(5, &Op::SetRating { id, rating: 1 }));
|
||||
for log in [format!("{log}{}", future(4)), format!("{log}{}{after}", future(4)), format!("{}{log}", future(3))] {
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snap.clone().into_bytes());
|
||||
m.set(LOG, log.into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
}
|
||||
// a known op with a field of an unknown shape is "newer" too
|
||||
let body = r#"{"op":"setRating","id":1,"rating":"five stars"}"#;
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, snap.into_bytes());
|
||||
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes());
|
||||
assert_refused_untouched(&m);
|
||||
}
|
||||
|
||||
/// A CRC mismatch is still damage / a torn tail (not "newer").
|
||||
#[test]
|
||||
fn bad_crc_is_still_a_torn_tail() {
|
||||
let (base, log, _) = legacy_parts();
|
||||
let m = MemStore::new();
|
||||
m.set(
|
||||
SNAPSHOT,
|
||||
format!("{{\"format\":\"lightcraft-catalog\",\"version\":{VERSION},\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes(),
|
||||
);
|
||||
let body = r#"{"op":"fromTheFuture"}"#;
|
||||
m.set(LOG, format!("{log}{{\"seq\":4,\"crc\":1,\"op\":{body}}}\n").into_bytes());
|
||||
let (_, _, r) = Journal::open(Box::new(m)).unwrap();
|
||||
assert!(r.torn_bytes > 0 && r.damaged.is_none(), "{r:?}");
|
||||
}
|
||||
|
||||
/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks
|
||||
/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the
|
||||
/// variant here under the new version.
|
||||
#[test]
|
||||
fn op_variants_are_versioned() {
|
||||
fn since(op: &Op) -> u32 {
|
||||
match op {
|
||||
Op::AddPhoto { .. }
|
||||
| Op::RemovePhoto { .. }
|
||||
| Op::SetRating { .. }
|
||||
| Op::SetFlag { .. }
|
||||
| Op::SetLabel { .. }
|
||||
| Op::SetDevelop { .. }
|
||||
| Op::SetMeta { .. }
|
||||
| Op::SetDeleted { .. }
|
||||
| Op::SetLocal { .. }
|
||||
| Op::SetVersions { .. }
|
||||
| Op::SetHistory { .. }
|
||||
| Op::PushHistory { .. }
|
||||
| Op::AddAlbum { .. }
|
||||
| Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumPhotos { .. }
|
||||
| Op::SetAlbumCover { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::AddStack { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetStack { .. }
|
||||
| Op::SetCaptured { .. }
|
||||
| Op::SetAnalysis { .. }
|
||||
| Op::SetFile { .. }
|
||||
| Op::Relink { .. }
|
||||
| Op::SetContent { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::Batch { .. } => 1,
|
||||
Op::SetBrowsed { .. } => 2,
|
||||
}
|
||||
}
|
||||
let newest = since(&Op::SetBrowsed { folder: String::new(), at: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
}
|
||||
@@ -283,7 +283,12 @@ fn streamed_snapshot_keeps_the_on_disk_format() {
|
||||
let (mut j, c, _) = open(&m);
|
||||
assert_eq!(c.to_snapshot(), full.to_snapshot());
|
||||
j.snapshot(&c).unwrap();
|
||||
let legacy = format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{},\"catalog\":{}}}\n", j.seq(), c.to_snapshot());
|
||||
let legacy = format!(
|
||||
"{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":{},\"catalog\":{}}}\n",
|
||||
crate::journal::VERSION,
|
||||
j.seq(),
|
||||
c.to_snapshot()
|
||||
);
|
||||
assert_eq!(String::from_utf8(m.get(SNAPSHOT).unwrap()).unwrap(), legacy);
|
||||
assert_eq!(j.stats().last_snapshot.bytes, legacy.len() as u64);
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Issue #99: one process per library.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::lock::{LOCK, OWNER};
|
||||
use crate::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
let dir = temp_dir("twice");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(first.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!((owner.pid, owner.program.as_str()), (std::process::id(), "LightCraft"));
|
||||
|
||||
let e = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, std::process::id());
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("already open in LightCraft") && msg.contains(&format!("process {}", std::process::id())), "{msg}");
|
||||
|
||||
drop(first);
|
||||
assert!(!dir.join(OWNER).exists(), "the owner note goes with the lock");
|
||||
assert!(dir.join(LOCK).exists(), "the lock file itself may stay: only a held lock refuses");
|
||||
let again = LibraryLock::acquire(&dir, "lightcraft-cli").unwrap();
|
||||
assert!(again.held());
|
||||
drop(again);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Files left behind by a crash (lock file + owner note) don't lock anyone out.
|
||||
#[test]
|
||||
fn leftover_lock_files_are_not_a_lock() {
|
||||
let dir = temp_dir("stale");
|
||||
std::fs::write(dir.join(LOCK), b"").unwrap();
|
||||
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
assert!(l.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!(owner.pid, std::process::id(), "the note now names this process");
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another process holds the lock; when it is killed (a crash), the OS releases it.
|
||||
#[test]
|
||||
fn lock_held_by_another_process_is_released_when_it_dies() {
|
||||
let dir = temp_dir("child");
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.args(["--exact", "tests_lock::lock_holder_child", "--ignored", "--test-threads=1"])
|
||||
.env("LC_LOCK_CHILD_DIR", &dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while !dir.join("child-locked").exists() {
|
||||
assert!(child.try_wait().unwrap().is_none(), "child exited before locking");
|
||||
assert!(std::time::Instant::now() < deadline, "child never locked");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
let e = LibraryLock::acquire(&dir, "LightCraft").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, child.id());
|
||||
assert!(e.to_string().contains("on this computer") || who.host.is_empty(), "{e}");
|
||||
|
||||
child.kill().unwrap(); // SIGKILL: no clean-up runs, like a crash
|
||||
child.wait().unwrap();
|
||||
assert!(dir.join(OWNER).exists(), "the crashed holder's note is left behind");
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").expect("the OS released the dead process's lock");
|
||||
assert!(l.held());
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Run by [`lock_held_by_another_process_is_released_when_it_dies`] in a child process: take the
|
||||
/// lock, say so, and wait to be killed.
|
||||
#[test]
|
||||
#[ignore = "helper process for lock_held_by_another_process_is_released_when_it_dies"]
|
||||
fn lock_holder_child() {
|
||||
let Some(dir) = std::env::var_os("LC_LOCK_CHILD_DIR") else { return };
|
||||
let _l = LibraryLock::acquire(std::path::Path::new(&dir), "lightcraft-cli").unwrap();
|
||||
std::fs::write(std::path::Path::new(&dir).join("child-locked"), b"").unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_secs(120));
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Is a file there? — answered without blocking the caller.
|
||||
//!
|
||||
//! On a sleeping NAS, a dropped share or a USB drive spinning up, one `stat` can take as long as
|
||||
//! the OS or SMB timeout. The UI asks about files every frame (grid cells, the Info panel, the
|
||||
//! Missing Photos view), so in the app the answers come from a cache that a worker thread fills
|
||||
//! ([`Availability::run_in_background`]): an unknown file is reported as `None` ("assume it is
|
||||
//! there"; the render worker finds out if it isn't), known answers are rechecked after
|
||||
//! [`RECHECK`]. Everywhere else (CLI, MCP, tests) the default is synchronous: every question is a
|
||||
//! fresh check, as before.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Checks whether a path exists (injectable: tests use a slow one).
|
||||
pub type ExistsProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
|
||||
/// Called (from the worker) when an answer changed, e.g. to repaint the UI.
|
||||
pub type Waker = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// How long a cached answer is used before it is checked again (it is still returned meanwhile).
|
||||
pub const RECHECK: Duration = Duration::from_secs(15);
|
||||
|
||||
fn fs_exists(path: &str) -> bool {
|
||||
// the browser build has no file system: its originals are always "there"
|
||||
cfg!(target_arch = "wasm32") || std::path::Path::new(path).exists()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// path → (exists, when checked)
|
||||
known: HashMap<String, (bool, std::time::Instant)>,
|
||||
queue: VecDeque<String>,
|
||||
queued: HashSet<String>,
|
||||
worker: bool,
|
||||
/// Bumped whenever an answer changes (callers recompute what depends on it).
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
|
||||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// File availability, synchronous or cached + background-checked. Cloning shares the cache.
|
||||
#[derive(Clone)]
|
||||
pub struct Availability {
|
||||
probe: ExistsProbe,
|
||||
background: Option<Waker>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl Default for Availability {
|
||||
fn default() -> Self {
|
||||
Self { probe: Arc::new(fs_exists), background: None, state: Arc::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Availability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Availability").field("background", &self.background.is_some()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Availability {
|
||||
/// Replace the existence check (tests: a slow or counting probe). Forgets cached answers.
|
||||
pub fn set_probe(&mut self, probe: ExistsProbe) {
|
||||
self.probe = probe;
|
||||
let mut st = lock(&self.state);
|
||||
st.known.clear();
|
||||
st.generation += 1;
|
||||
}
|
||||
|
||||
/// Answer from the cache and check on a worker thread from now on; `wake` runs when an
|
||||
/// answer changed. No effect in the browser build (no threads, no file system).
|
||||
pub fn run_in_background(&mut self, wake: Waker) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
self.background = Some(wake);
|
||||
}
|
||||
|
||||
/// The existence check itself (blocking: for worker threads, e.g. the Rename preview's).
|
||||
pub fn probe(&self) -> ExistsProbe {
|
||||
self.probe.clone()
|
||||
}
|
||||
|
||||
pub fn is_background(&self) -> bool {
|
||||
self.background.is_some()
|
||||
}
|
||||
|
||||
/// Does `path` exist? Synchronous mode: checked now. Background mode: the cached answer
|
||||
/// (`None` while unknown); unknown or stale answers are queued for the worker.
|
||||
pub fn exists(&self, path: &str) -> Option<bool> {
|
||||
let Some(wake) = &self.background else {
|
||||
return Some((self.probe)(path));
|
||||
};
|
||||
let mut st = lock(&self.state);
|
||||
let now = std::time::Instant::now();
|
||||
let cached = st.known.get(path).copied();
|
||||
if cached.is_none_or(|(_, at)| now.saturating_duration_since(at) >= RECHECK) && !st.queued.contains(path) {
|
||||
st.queued.insert(path.to_string());
|
||||
st.queue.push_back(path.to_string());
|
||||
if !st.worker {
|
||||
st.worker = true;
|
||||
drop(st);
|
||||
self.spawn_worker(wake.clone());
|
||||
return cached.map(|c| c.0);
|
||||
}
|
||||
}
|
||||
cached.map(|c| c.0)
|
||||
}
|
||||
|
||||
/// Known to be missing (unknown counts as present).
|
||||
pub fn is_offline(&self, path: &str) -> bool {
|
||||
self.exists(path) == Some(false)
|
||||
}
|
||||
|
||||
/// Changes whenever a cached answer changes.
|
||||
pub fn generation(&self) -> u64 {
|
||||
lock(&self.state).generation
|
||||
}
|
||||
|
||||
/// Paths waiting for the worker.
|
||||
pub fn pending(&self) -> usize {
|
||||
lock(&self.state).queue.len()
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, wake: Waker) {
|
||||
let (state, probe) = (self.state.clone(), self.probe.clone());
|
||||
let started = std::thread::Builder::new().name("lc-availability".into()).spawn(move || {
|
||||
loop {
|
||||
let path = {
|
||||
let mut st = lock(&state);
|
||||
let Some(p) = st.queue.pop_front() else {
|
||||
st.worker = false;
|
||||
return;
|
||||
};
|
||||
p
|
||||
};
|
||||
let ok = probe(&path);
|
||||
let changed = {
|
||||
let mut st = lock(&state);
|
||||
st.queued.remove(&path);
|
||||
let prev = st.known.insert(path, (ok, std::time::Instant::now()));
|
||||
let changed = prev.map(|p| p.0) != Some(ok);
|
||||
if changed {
|
||||
st.generation += 1;
|
||||
}
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
if started.is_err() {
|
||||
// try again on the next question
|
||||
lock(&self.state).worker = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn synchronous_by_default() {
|
||||
let mut a = Availability::default();
|
||||
let n = Arc::new(AtomicUsize::new(0));
|
||||
let c = n.clone();
|
||||
a.set_probe(Arc::new(move |p| {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
p == "here"
|
||||
}));
|
||||
assert_eq!(a.exists("here"), Some(true));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert_eq!(n.load(Ordering::SeqCst), 3, "every question is a fresh check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_answers_from_the_cache_without_blocking() {
|
||||
let mut a = Availability::default();
|
||||
let caller = std::thread::current().id();
|
||||
let on_caller = Arc::new(AtomicUsize::new(0));
|
||||
let c = on_caller.clone();
|
||||
a.set_probe(Arc::new(move |p| {
|
||||
if std::thread::current().id() == caller {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200)); // a sleeping NAS
|
||||
p == "here"
|
||||
}));
|
||||
let woke = Arc::new(AtomicUsize::new(0));
|
||||
let w = woke.clone();
|
||||
a.run_in_background(Arc::new(move || {
|
||||
w.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
let t0 = std::time::Instant::now();
|
||||
assert_eq!(a.exists("here"), None, "unknown at first");
|
||||
assert_eq!(a.exists("gone"), None);
|
||||
assert!(t0.elapsed() < Duration::from_millis(150), "the caller never waits for the probe");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(20);
|
||||
while (a.exists("here").is_none() || a.exists("gone").is_none()) && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(a.exists("here"), Some(true));
|
||||
assert_eq!(a.exists("gone"), Some(false));
|
||||
assert!(a.is_offline("gone") && !a.is_offline("here") && !a.is_offline("unknown"));
|
||||
assert_eq!(on_caller.load(Ordering::SeqCst), 0, "never probed on the calling thread");
|
||||
assert!(woke.load(Ordering::SeqCst) >= 2);
|
||||
assert!(a.generation() >= 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Estimate an ARW starting look from its own JPEG. Colour and luminance are fitted separately;
|
||||
//! the JPEG supplies correspondences only, never output pixels or a replacement for RAW editing.
|
||||
use lightcraft_color::{Mat3, luminance_2020};
|
||||
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
|
||||
use lightcraft_raster::{
|
||||
Rgb32f,
|
||||
resample::{Filter, fit},
|
||||
};
|
||||
use lightcraft_raw::{RawImage, color::CameraTransform};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
pub matrix: Mat3,
|
||||
pub tone: CameraTone,
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw {
|
||||
return None;
|
||||
}
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((384, 384)), max_pixels: 64_000_000 }).ok()?;
|
||||
let reference = decoded.to_working();
|
||||
let crop = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let aspect = crop.width as f64 / crop.height as f64;
|
||||
if (reference.width as f64 / reference.height as f64 / aspect - 1.0).abs() > 0.02 {
|
||||
return None;
|
||||
}
|
||||
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
|
||||
let k = (crop.width.max(crop.height).div_ceil(384).max(2)).div_ceil(2) * 2;
|
||||
let sensor = raw.develop_binned(k, 0.99).ok()??;
|
||||
let mut sensor = fit(&sensor, 96, 96, Filter::Box);
|
||||
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
|
||||
let look = fit_pairs(&sensor, &reference)?;
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] ARW camera look: {:?}, {:?}", look.matrix.0, look.tone);
|
||||
}
|
||||
Some(look)
|
||||
}
|
||||
|
||||
/// A fit must cut the held-out squared error to below this share of the fallback's.
|
||||
const MIN_IMPROVEMENT: f64 = 0.7;
|
||||
/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public
|
||||
/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed
|
||||
/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve
|
||||
/// cannot follow): visibly better renders that 0.055 rejected.
|
||||
const MAX_HOLDOUT_RMS: f64 = 0.10;
|
||||
|
||||
fn luma(p: [f64; 3]) -> f64 {
|
||||
p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593
|
||||
}
|
||||
|
||||
fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] {
|
||||
let scene = scene.map(|v| v.max(0.0));
|
||||
let y = luma(scene);
|
||||
if y <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
scene.map(|v| v * f64::from(tone.apply(y as f32)) / y)
|
||||
}
|
||||
|
||||
fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
let mut colour = 0;
|
||||
for (input, output) in sensor.data.iter().zip(&reference.data) {
|
||||
let y = luminance_2020(*output);
|
||||
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
|
||||
|| !output.iter().all(|v| v.is_finite() && *v > 0.004 && *v < 0.98)
|
||||
|| !(0.015..0.85).contains(&y)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let max = output.iter().copied().fold(0.0, f32::max);
|
||||
colour += usize::from(max - min > 0.05);
|
||||
pairs.push((input.map(f64::from), output.map(f64::from)));
|
||||
}
|
||||
if pairs.len() < 256 || colour < pairs.len() / 20 {
|
||||
return None;
|
||||
}
|
||||
let mut gram = [[0.0; 3]; 3];
|
||||
let mut cross = [[0.0; 3]; 3];
|
||||
for (i, (x, y)) in pairs.iter().enumerate() {
|
||||
if i % 3 == 0 {
|
||||
continue;
|
||||
}
|
||||
let (lx, ly) = (luma(*x), luma(*y));
|
||||
for row in 0..3 {
|
||||
for col in 0..3 {
|
||||
// Normalising by luminance prevents a camera S-curve from corrupting colour.
|
||||
gram[row][col] += x[row] * x[col] / (lx * lx);
|
||||
cross[row][col] += y[row] * x[col] / (ly * lx);
|
||||
}
|
||||
}
|
||||
}
|
||||
let trace: f64 = (0..3).map(|i| gram[i][i]).sum();
|
||||
let inverse = Mat3(gram).inverse()?;
|
||||
let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::<f64>();
|
||||
if trace <= 0.0 || !condition.is_finite() || condition > 1e6 {
|
||||
return None;
|
||||
}
|
||||
let regularization = trace * 1e-4;
|
||||
for i in 0..3 {
|
||||
gram[i][i] += regularization;
|
||||
cross[i][i] += regularization;
|
||||
}
|
||||
let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?);
|
||||
if !matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0) {
|
||||
return None;
|
||||
}
|
||||
let tone_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let curve = fit_tone(tone_pairs)?;
|
||||
let tone = ToneMap::camera(&curve, 0.0, 0.0, 0.0);
|
||||
let original_tone = ToneMap::new(0.0, 0.0, 0.0);
|
||||
let mut before = 0.0;
|
||||
let mut after = 0.0;
|
||||
let mut samples = 0;
|
||||
for (i, (x, target)) in pairs.iter().enumerate() {
|
||||
if i % 3 != 0 {
|
||||
continue;
|
||||
}
|
||||
let corrected = displayed(matrix.apply(*x), &tone);
|
||||
let original = displayed(*x, &original_tone);
|
||||
for c in 0..3 {
|
||||
before += (original[c] - target[c]).powi(2);
|
||||
after += (corrected[c] - target[c]).powi(2);
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] ARW holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt());
|
||||
}
|
||||
if !after.is_finite() || samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
|
||||
return None;
|
||||
}
|
||||
Some(CameraLook { matrix, tone: curve })
|
||||
}
|
||||
|
||||
fn median(values: &mut [f64]) -> Option<f64> {
|
||||
values.sort_by(f64::total_cmp);
|
||||
values.get(values.len() / 2).copied()
|
||||
}
|
||||
|
||||
fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut knots = [[0.0; 2]; 32];
|
||||
for (i, knot) in knots.iter_mut().enumerate() {
|
||||
let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?;
|
||||
let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect();
|
||||
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
|
||||
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
|
||||
}
|
||||
if knots[31][0] < knots[0][0] * 1.5 {
|
||||
return None;
|
||||
}
|
||||
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
|
||||
let mut blocks: Vec<(f32, usize)> = Vec::new();
|
||||
for knot in knots {
|
||||
blocks.push((knot[1], 1));
|
||||
while blocks.len() >= 2 {
|
||||
let (a, an) = *blocks.get(blocks.len() - 2)?;
|
||||
let (b, bn) = *blocks.last()?;
|
||||
if a <= b {
|
||||
break;
|
||||
}
|
||||
blocks.truncate(blocks.len() - 2);
|
||||
blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn));
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
for (y, n) in blocks {
|
||||
for knot in knots.get_mut(i..i + n)? {
|
||||
knot[1] = y;
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
CameraTone::new(knots)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let mut sensor = Rgb32f::new(64, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.05 + (i % 31) as f32 * 0.017;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
|
||||
}
|
||||
let original = sensor.clone();
|
||||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||||
assert_eq!(sensor.data, original.data);
|
||||
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||||
let error: f64 = sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone);
|
||||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ sensor.data.len() as f64;
|
||||
assert!(error.sqrt() < 0.025, "{error}");
|
||||
// The colour transform is homogeneous; tone mapping happens only after exposure.
|
||||
let p = fit.matrix.apply([2.0, 2.0, 2.0]);
|
||||
assert!(luma(p) > 1.0);
|
||||
assert!(tone.apply(0.2) < tone.apply(0.4));
|
||||
}
|
||||
#[test]
|
||||
fn accepts_a_much_better_fit_despite_local_camera_processing() {
|
||||
// The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12
|
||||
// per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback.
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let mut sensor = Rgb32f::new(64, 64);
|
||||
let mut reference = sensor.clone();
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.05 + (i % 31) as f32 * 0.017;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let noise = (seed % 2001) as f32 / 1000.0 - 1.0;
|
||||
(v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97)
|
||||
});
|
||||
}
|
||||
assert!(fit_pairs(&sensor, &reference).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, p) in sensor.data.iter_mut().enumerate() {
|
||||
*p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1];
|
||||
}
|
||||
reference.data.fill([0.2; 3]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
reference.data.fill([f32::NAN; 3]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
*src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01];
|
||||
*dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
|
||||
}
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
sensor.data.fill([0.1, 0.15, 0.12]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
reference.data.truncate(8);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; not an undo step → {path, relinked}",
|
||||
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken) → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; not an undo step → {path, relinked}",
|
||||
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken) → {path, relinked}",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
|
||||
@@ -217,58 +217,85 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside. Like a rename in the file manager it is not an undo step: undoing would
|
||||
/// point the photos back at a folder that no longer exists.
|
||||
pub(crate) fn move_folder(s: &mut crate::Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
use std::path::Path;
|
||||
/// Rename `from` to `to` on disk (a folder; everything in it goes along). Never overwrites:
|
||||
/// refused when `to` exists (a case-only rename on a case-insensitive file system goes through
|
||||
/// a temporary name). Missing parents of `to` are created.
|
||||
pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
return Err(format!("{from} is not a folder (moved or deleted?)"));
|
||||
}
|
||||
if dst.exists() {
|
||||
let case_only = from != to && from.to_lowercase() == to.to_lowercase();
|
||||
if dst.exists() && !case_only {
|
||||
return Err(format!("{to} already exists"));
|
||||
}
|
||||
if dst.starts_with(src) {
|
||||
return Err("a folder can't move into itself".into());
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
let mut n = 0;
|
||||
let ops: Vec<lightcraft_catalog::Op> = s
|
||||
if case_only {
|
||||
let tmp = src.with_file_name(format!(".lc-rename-{}", std::process::id()));
|
||||
if tmp.exists() {
|
||||
return Err(format!("{} already exists", tmp.display()));
|
||||
}
|
||||
std::fs::rename(src, &tmp).map_err(|e| format!("{from} → {to}: {e}"))?;
|
||||
return std::fs::rename(&tmp, dst).map_err(|e| {
|
||||
let _ = std::fs::rename(&tmp, src);
|
||||
format!("{from} → {to}: {e}")
|
||||
});
|
||||
}
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
|
||||
}
|
||||
|
||||
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
|
||||
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(from)
|
||||
{
|
||||
b.path = Path::new(to).join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside, as one undo step: undo renames the folder back and restores the photos'
|
||||
/// paths, redo repeats the move — each refused (and reported) when the destination is taken.
|
||||
pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result::Result<usize, String> {
|
||||
let (src, dst) = (Path::new(from), Path::new(to));
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let ops: Vec<Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))),
|
||||
_ => None,
|
||||
})
|
||||
.map(|(id, file_name, np)| {
|
||||
n += 1;
|
||||
lightcraft_catalog::Op::Relink {
|
||||
id,
|
||||
file_name,
|
||||
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
|
||||
format: None,
|
||||
}
|
||||
.map(|(id, file_name, np)| Op::Relink {
|
||||
id,
|
||||
file_name,
|
||||
source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() },
|
||||
format: None,
|
||||
})
|
||||
.collect();
|
||||
for op in ops {
|
||||
if s.catalog.apply(op.clone()).is_ok() {
|
||||
s.pending_log.push(op);
|
||||
}
|
||||
}
|
||||
// a browsed folder at or below it follows
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(src)
|
||||
{
|
||||
b.path = dst.join(rel).to_string_lossy().to_string();
|
||||
let f = b.path.clone();
|
||||
s.filter.folder = Some(f);
|
||||
let n = ops.len();
|
||||
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let renamed = src.parent() == dst.parent();
|
||||
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
|
||||
let folder = crate::FolderMove { from: to.to_string(), to: from.to_string() };
|
||||
if let Err(e) = s.commit_with_folder(&label, Op::Batch { ops }, folder) {
|
||||
// the catalog didn't take it: put the folder back where the photos still point
|
||||
let back = rename_folder_on_disk(to, from).err().map(|b| format!("; moving it back failed too: {b}")).unwrap_or_default();
|
||||
return Err(format!("{e}{back}"));
|
||||
}
|
||||
follow_folder(s, from, to);
|
||||
Ok(n)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const PROBE_EDGE: usize = 384;
|
||||
pub(crate) fn probe(s: &mut Session, c: &str, x: f64, y: f64, neutral: impl FnOnce(&mut DevelopSettings)) -> Result<[f32; 3]> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad(c, e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
neutral(&mut d);
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(PROBE_EDGE, PROBE_EDGE);
|
||||
|
||||
@@ -26,7 +26,12 @@ fn dng_names(path: &str) -> impl Iterator<Item = std::path::PathBuf> {
|
||||
/// written to a temp file, synced, read back identical and only then given its name — never
|
||||
/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use.
|
||||
pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result<String, String> {
|
||||
let bytes = match &s.media.file_bytes {
|
||||
write_dng_with(s.media.file_bytes.as_ref(), path, packet)
|
||||
}
|
||||
|
||||
/// [`write_dng_for`] without the session (imports convert on a worker thread).
|
||||
pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path: &str, packet: String) -> std::result::Result<String, String> {
|
||||
let bytes = match file_bytes {
|
||||
Some(r) => r(path)?,
|
||||
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
|
||||
};
|
||||
|
||||
@@ -98,7 +98,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
||||
let apply = |d: &mut DevelopSettings| {
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
||||
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
for (k, v) in &vals {
|
||||
controls::set(d, k, *v);
|
||||
if k == "wb.temp" || k == "wb.tint" {
|
||||
@@ -113,15 +117,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut d = (*d).clone();
|
||||
apply(&mut d);
|
||||
let info = s.source_info(id);
|
||||
apply(&mut d, &info);
|
||||
s.develop_op(id, d, &label)
|
||||
})
|
||||
.collect();
|
||||
s.commit(&label, Op::Batch { ops })?;
|
||||
return ok();
|
||||
}
|
||||
let id = active(s, "develop.set")?;
|
||||
let info = s.source_info(id);
|
||||
edit(s, "develop.set", &label, |d| {
|
||||
apply(d);
|
||||
apply(d, &info);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -130,7 +137,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
|
||||
let delta = f64_req(p, "delta", "develop.adjust")?;
|
||||
let info = s.source_info(active(s, "develop.adjust")?);
|
||||
edit(s, "develop.adjust", spec.label, |d| {
|
||||
if c == "wb.temp" || c == "wb.tint" {
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
d.wb.mode = WbMode::Custom;
|
||||
}
|
||||
let v = controls::get(d, &c).unwrap_or(spec.default);
|
||||
controls::set(d, &c, v + delta);
|
||||
Ok(())
|
||||
@@ -149,12 +164,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
.filter_map(|(id, _d)| {
|
||||
// back to the photo's import defaults when a default preset gave it its look
|
||||
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
|
||||
let fresh = match look {
|
||||
Some(l) => (*l).clone(),
|
||||
None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
|
||||
None => {
|
||||
let info = s.source_info(id);
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
})
|
||||
@@ -203,7 +224,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| {
|
||||
let id = active(s, "develop.autoBwMix")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
|
||||
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
|
||||
@@ -218,7 +239,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
|
||||
let id = active(s, "develop.auto")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let a = lightcraft_pipeline::auto::auto_tone(&src, &info, &d);
|
||||
edit(s, "develop.auto", "Auto", |d| {
|
||||
@@ -245,7 +266,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mode: WbMode =
|
||||
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("custom"))).map_err(|e| bad("develop.wb", e.to_string()))?;
|
||||
let id = active(s, "develop.wb")?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let (mut t, mut tint) = match mode {
|
||||
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
|
||||
WbMode::Auto => {
|
||||
@@ -256,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.preset()
|
||||
.map(|(t, ti)| {
|
||||
// presets are relative to daylight for rendered files
|
||||
if info.raw { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
})
|
||||
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
|
||||
};
|
||||
@@ -277,7 +298,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (x, y) = (f64_req(p, "x", "develop.wbPick")?, f64_req(p, "y", "develop.wbPick")?);
|
||||
let id = active(s, "develop.wbPick")?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.wbPick", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
// Sample a small neighbourhood in the oriented source.
|
||||
let o = src.oriented(d.orientation);
|
||||
@@ -405,7 +426,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| {
|
||||
let id = active(s, "crop.autoStraighten")?;
|
||||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else {
|
||||
return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"}));
|
||||
@@ -501,7 +522,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Upright::Off | Upright::Guided => None,
|
||||
m => {
|
||||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("geometry.upright", e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
Some(lightcraft_pipeline::upright::auto_transform(&src, &info, &d, m))
|
||||
}
|
||||
};
|
||||
@@ -657,6 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut nd = (*d).clone();
|
||||
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
|
||||
let info = s.source_info(id);
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
|
||||
@@ -7,6 +7,105 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
||||
let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;
|
||||
Ok(rd
|
||||
.flatten()
|
||||
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
|
||||
.filter_map(|e| {
|
||||
let m = std::fs::metadata(e.path()).ok()?;
|
||||
m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `library.import`'s params, checked (no file-system calls).
|
||||
pub struct ImportRequest {
|
||||
pub paths: Vec<String>,
|
||||
pub opts: crate::import::ImportOptions,
|
||||
pub album: Option<u64>,
|
||||
pub album_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse and check `library.import`'s params (the app's import task runs the import itself, on a
|
||||
/// worker thread, with the same options).
|
||||
pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
let paths = strs(p, "paths");
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::Copy,
|
||||
"move" => crate::import::ImportMode::Move,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
|
||||
};
|
||||
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
|
||||
Some(id) => Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let album = p.get("album").and_then(Value::as_u64);
|
||||
if let Some(a) = album
|
||||
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
|
||||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
let organize = match str_param(p, "organize") {
|
||||
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
|
||||
bad("library.import", format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"))
|
||||
})?,
|
||||
None => Default::default(),
|
||||
};
|
||||
if let crate::import::Organize::Template(t) = &organize
|
||||
&& let Some(e) = crate::rename::folder_template_error(t)
|
||||
{
|
||||
return Err(bad("library.import", e));
|
||||
}
|
||||
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &metadata_preset
|
||||
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
|
||||
{
|
||||
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
||||
}
|
||||
let opts = crate::import::ImportOptions {
|
||||
mode,
|
||||
preset,
|
||||
keywords: strs(p, "keywords"),
|
||||
destination: str_param(p, "destination").map(str::to_string),
|
||||
organize,
|
||||
rename: str_param(p, "rename").map(str::to_string),
|
||||
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
|
||||
metadata_preset,
|
||||
convert_dng: bool_or(p, "dng", false),
|
||||
local: bool_or(p, "local", false),
|
||||
};
|
||||
let album_name = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
Ok(ImportRequest { paths, opts, album, album_name })
|
||||
}
|
||||
|
||||
/// After a batch of an import was committed: the album (a new one named `album_name` when there
|
||||
/// is no `album` and something was imported) gets the photos. Returns the report as JSON (with
|
||||
/// `album`).
|
||||
pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option<u64>, album_name: Option<&str>) -> Result<Value> {
|
||||
let mut report = serde_json::to_value(report).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
if album.is_none()
|
||||
&& let Some(name) = album_name
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
let r = s.execute("album.create", &json!({"name": name}))?;
|
||||
album = r["id"].as_u64();
|
||||
}
|
||||
if let Some(a) = album
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
// (album.addPhotos wants a selection: the new photos are about to be it)
|
||||
s.selection = Selection::single(PhotoId(imported[0]));
|
||||
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
|
||||
report["album"] = json!(a);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
|
||||
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
|
||||
}
|
||||
@@ -657,9 +756,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Auto Import Now",
|
||||
[],
|
||||
None,
|
||||
"{} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder}",
|
||||
"{listing?: [[path, size]] (the folder as listed by the caller, e.g. on a worker thread), start?: bool (false: return the `library.import` params as `import` instead of importing)} — add the watched folder's new photos (files the library doesn't have yet; partial / still-copying files wait for the next scan) → {imported, folder, import?}",
|
||||
always,
|
||||
|s, _| {
|
||||
|s, p| {
|
||||
let Some(folder) = s.import_defaults.auto_folder.clone() else { return Ok(json!({"imported": [], "folder": null})) };
|
||||
// only files that stopped growing: a file still being written is left for later
|
||||
let known: std::collections::HashSet<String> = s
|
||||
@@ -670,18 +769,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// the folder's files and sizes: listed here, or already listed on a worker thread (the app)
|
||||
let listing: Vec<(String, u64)> = match p.get("listing").and_then(Value::as_array) {
|
||||
Some(l) => l.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_u64()?))).collect(),
|
||||
None => list_auto_import_folder(&folder).map_err(|e| bad("library.autoImportScan", e))?,
|
||||
};
|
||||
let mut fresh = Vec::new();
|
||||
for e in std::fs::read_dir(&folder).map_err(|e| bad("library.autoImportScan", format!("{folder}: {e}")))?.flatten() {
|
||||
let path = e.path();
|
||||
let ps = path.to_string_lossy().to_string();
|
||||
if !path.is_file() || known.contains(&ps) || path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
||||
for (ps, size) in listing {
|
||||
if known.contains(&ps) {
|
||||
continue;
|
||||
}
|
||||
// each file is tried once (a non-photo isn't retried every scan)
|
||||
if s.auto_import_seen.get(&ps) == Some(&u64::MAX) {
|
||||
continue;
|
||||
}
|
||||
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let seen = s.auto_import_seen.insert(ps.clone(), size);
|
||||
if size > 0 && seen == Some(size) {
|
||||
fresh.push(ps);
|
||||
@@ -701,6 +802,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for f in &fresh {
|
||||
s.auto_import_seen.insert(f.clone(), u64::MAX);
|
||||
}
|
||||
if p.get("start").and_then(Value::as_bool) == Some(false) {
|
||||
// the caller imports them (the app: on a worker thread)
|
||||
return Ok(json!({"imported": [], "folder": folder, "import": params}));
|
||||
}
|
||||
let sel = s.selection.clone();
|
||||
let r = s.execute("library.import", ¶ms)?;
|
||||
// arriving photos don't take over the selection
|
||||
@@ -751,79 +856,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
if paths.is_empty() {
|
||||
let req = import_params(s, p)?;
|
||||
if req.paths.is_empty() {
|
||||
return Err(bad("library.import", "no paths"));
|
||||
}
|
||||
let mode = match str_param(p, "mode").unwrap_or("add") {
|
||||
"add" => crate::import::ImportMode::Add,
|
||||
"copy" => crate::import::ImportMode::Copy,
|
||||
"move" => crate::import::ImportMode::Move,
|
||||
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy|move)"))),
|
||||
};
|
||||
let preset = match str_param(p, "preset").filter(|x| !x.is_empty()) {
|
||||
Some(id) => {
|
||||
Some(s.presets.iter().find(|x| x.id == id).cloned().ok_or_else(|| bad("library.import", format!("unknown preset `{id}`")))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut album = p.get("album").and_then(Value::as_u64);
|
||||
if let Some(a) = album
|
||||
&& s.catalog.album(AlbumId(a)).is_none_or(|al| al.folder || al.is_smart())
|
||||
{
|
||||
return Err(bad("library.import", "album must be a regular album"));
|
||||
}
|
||||
let organize = match str_param(p, "organize") {
|
||||
Some(o) => crate::import::Organize::parse(o).ok_or_else(|| {
|
||||
bad(
|
||||
"library.import",
|
||||
format!("unknown organize `{o}` (date|month|flat, or a folder template like {{date:%Y}}/{{date:%Y%m%d}})"),
|
||||
)
|
||||
})?,
|
||||
None => Default::default(),
|
||||
};
|
||||
if let crate::import::Organize::Template(t) = &organize
|
||||
&& let Some(e) = crate::rename::folder_template_error(t)
|
||||
{
|
||||
return Err(bad("library.import", e));
|
||||
}
|
||||
let metadata_preset = str_param(p, "metadataPreset").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &metadata_preset
|
||||
&& !s.metadata_presets.iter().any(|m| m.name.eq_ignore_ascii_case(n))
|
||||
{
|
||||
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
||||
}
|
||||
let opts = crate::import::ImportOptions {
|
||||
mode,
|
||||
preset,
|
||||
keywords: strs(p, "keywords"),
|
||||
destination: str_param(p, "destination").map(str::to_string),
|
||||
organize,
|
||||
rename: str_param(p, "rename").map(str::to_string),
|
||||
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
|
||||
metadata_preset,
|
||||
convert_dng: super::bool_or(p, "dng", false),
|
||||
local: super::bool_or(p, "local", false),
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
if album.is_none()
|
||||
&& let Some(name) = str_param(p, "albumName").map(str::trim).filter(|n| !n.is_empty())
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
let r = s.execute("album.create", &json!({"name": name}))?;
|
||||
album = r["id"].as_u64();
|
||||
}
|
||||
if let Some(a) = album
|
||||
&& !imported.is_empty()
|
||||
{
|
||||
// (album.addPhotos wants a selection: the new photos are about to be it)
|
||||
s.selection = Selection::single(PhotoId(imported[0]));
|
||||
s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?;
|
||||
report["album"] = json!(a);
|
||||
}
|
||||
let report = crate::import::import_with(s, &req.paths, &req.opts)?;
|
||||
let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?;
|
||||
// one undo step for the whole import
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
let n = s.undo.len().saturating_sub(undo0);
|
||||
s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" }));
|
||||
if let Some(f) = imported.first() {
|
||||
@@ -867,6 +908,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"logRecords": j.log_records(),
|
||||
"logBytes": j.log_bytes(),
|
||||
"lastError": lib.last_error,
|
||||
// settings files that were unreadable or damaged at open (kept, defaults used)
|
||||
"settingsWarnings": lib.settings_warnings,
|
||||
// changes applied in memory whose write failed (retried by every save)
|
||||
"unsavedOps": s.unsaved().map_or(0, |u| u.0),
|
||||
"unsavedError": s.unsaved().map(|u| u.1),
|
||||
|
||||
@@ -185,7 +185,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let info = s.source_info(id);
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
|
||||
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
|
||||
@@ -646,7 +646,7 @@ fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spo
|
||||
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
|
||||
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
|
||||
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
|
||||
let info = crate::media::source_info(s.catalog.photo(id)?);
|
||||
let info = s.source_info(id);
|
||||
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,10 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
|
||||
}
|
||||
|
||||
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
|
||||
/// the photos its query already narrowed to).
|
||||
pub fn is_missing(cat: &Catalog, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| !Path::new(f).exists())
|
||||
/// the photos its query already narrowed to). In the app `avail` answers from its cache (files
|
||||
/// not checked yet count as present until the background check finds them gone).
|
||||
pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id: PhotoId) -> bool {
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
@@ -94,8 +95,112 @@ fn file_hash(path: &str) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string())
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file and relink every match in one
|
||||
/// undoable step.
|
||||
/// An in-scope photo ([`checked_path`]) as Find Missing Photos sees it: its file, the size and
|
||||
/// content hash the library knows (0 / `None` = unknown).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FindCandidate {
|
||||
pub id: PhotoId,
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Every in-scope photo, for [`plan_find_missing`]: the missing ones are looked for, and no
|
||||
/// photo's file is used for another. No file-system calls, so the app takes this snapshot on the
|
||||
/// UI thread and plans on a worker without holding the session.
|
||||
pub fn find_candidates(cat: &Catalog) -> Vec<FindCandidate> {
|
||||
cat.photos()
|
||||
.filter_map(|p| {
|
||||
checked_path(p).map(|f| FindCandidate { id: p.id, path: f.to_string(), size: p.file_size, hash: stored_hash(p).map(str::to_string) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How a missing photo's file was recognised.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MatchBy {
|
||||
/// Same name and size (and content, when the library knows its hash).
|
||||
Name,
|
||||
/// Renamed: same size and content hash.
|
||||
Content,
|
||||
}
|
||||
|
||||
impl MatchBy {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
MatchBy::Name => "name",
|
||||
MatchBy::Content => "content",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A missing photo and the file found for it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FoundFile {
|
||||
pub id: PhotoId,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub by: MatchBy,
|
||||
}
|
||||
|
||||
/// A missing photo with several look-alike files (same name and size, no hash to tell them
|
||||
/// apart): skipped, never guessed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AmbiguousFile {
|
||||
pub id: PhotoId,
|
||||
pub from: String,
|
||||
pub candidates: Vec<String>,
|
||||
}
|
||||
|
||||
/// What Find Missing Photos found ([`plan_find_missing`]), before relinking.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FindPlan {
|
||||
pub found: Vec<FoundFile>,
|
||||
/// In-scope photos that stay missing (the ambiguous ones included).
|
||||
pub missing: usize,
|
||||
pub ambiguous: Vec<AmbiguousFile>,
|
||||
}
|
||||
|
||||
impl FindPlan {
|
||||
/// `{found: [{id, from, to, by}], missing, ambiguous: [{id, from, candidates}]}` — the
|
||||
/// command's result, and what `library.findMissing {found, …}` takes back.
|
||||
pub fn to_json(&self) -> Value {
|
||||
let found: Vec<Value> = self.found.iter().map(|f| json!({"id": f.id.0, "from": f.from, "to": f.to, "by": f.by.as_str()})).collect();
|
||||
let ambiguous: Vec<Value> = self.ambiguous.iter().map(|a| json!({"id": a.id.0, "from": a.from, "candidates": a.candidates})).collect();
|
||||
json!({"found": found, "missing": self.missing, "ambiguous": ambiguous})
|
||||
}
|
||||
|
||||
/// The inverse of [`FindPlan::to_json`] (entries it can't read are dropped).
|
||||
pub fn from_json(v: &Value) -> FindPlan {
|
||||
let list = |k: &str| v.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let found = list("found")
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let by = if f.get("by").and_then(Value::as_str) == Some("content") { MatchBy::Content } else { MatchBy::Name };
|
||||
Some(FoundFile {
|
||||
id: PhotoId(f.get("id")?.as_u64()?),
|
||||
from: str_param(f, "from")?.to_string(),
|
||||
to: str_param(f, "to")?.to_string(),
|
||||
by,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ambiguous = list("ambiguous")
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
let candidates = a.get("candidates")?.as_array()?.iter().filter_map(|c| c.as_str().map(str::to_string)).collect();
|
||||
Some(AmbiguousFile { id: PhotoId(a.get("id")?.as_u64()?), from: str_param(a, "from")?.to_string(), candidates })
|
||||
})
|
||||
.collect();
|
||||
let missing = v.get("missing").and_then(Value::as_u64).map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX));
|
||||
FindPlan { found, missing, ambiguous }
|
||||
}
|
||||
}
|
||||
|
||||
/// The file-system half of Find Missing Photos: which `candidates` are gone, and which file in
|
||||
/// `folder` (recursively) is each one's. Touches only the disk — never the session — so the app
|
||||
/// runs it on a worker thread (walking a big folder on a network share and hashing look-alikes
|
||||
/// takes a while) and relinks the result with `library.findMissing {found, missing, ambiguous}`.
|
||||
///
|
||||
/// Matching, per photo:
|
||||
/// 1. a file with the same name (any letter case), the same size and the same content hash;
|
||||
@@ -107,20 +212,21 @@ fn file_hash(path: &str) -> Option<String> {
|
||||
///
|
||||
/// Files that are already some photo's original, or that another photo was just matched with,
|
||||
/// are not used.
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findMissing";
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
|
||||
pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::result::Result<FindPlan, String> {
|
||||
if !Path::new(folder).is_dir() {
|
||||
return Err(bad(C, format!("{folder}: not a folder")));
|
||||
return Err(format!("{folder}: not a folder"));
|
||||
}
|
||||
let lost = missing(s);
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(FindPlan::default());
|
||||
}
|
||||
let lost: Vec<&FindCandidate> = candidates.iter().filter(|c| !Path::new(&c.path).exists()).collect();
|
||||
if lost.is_empty() {
|
||||
return Ok(json!({"found": [], "missing": 0, "ambiguous": []}));
|
||||
return Ok(FindPlan::default());
|
||||
}
|
||||
let in_use: HashSet<String> = s.catalog.photos().filter_map(|p| checked_path(p).map(str::to_string)).collect();
|
||||
let in_use: HashSet<&str> = candidates.iter().map(|c| c.path.as_str()).collect();
|
||||
// file name (lower case) → candidate paths; size → paths (filled lazily)
|
||||
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f)).collect();
|
||||
let files: Vec<String> = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f.as_str())).collect();
|
||||
for f in &files {
|
||||
if let Some(n) = Path::new(f).file_name() {
|
||||
by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone());
|
||||
@@ -131,24 +237,21 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut hash_of = |f: &str| hashes.entry(f.to_string()).or_insert_with(|| file_hash(f)).clone();
|
||||
let size_of = |f: &str| std::fs::metadata(f).map(|m| m.len()).ok();
|
||||
let mut used: HashSet<String> = HashSet::new();
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
let mut ambiguous = Vec::new();
|
||||
for (id, old) in &lost {
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let name = Path::new(old).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = ph.file_size;
|
||||
let hash = stored_hash(ph).map(str::to_string);
|
||||
let mut plan = FindPlan::default();
|
||||
for c in &lost {
|
||||
let name = Path::new(&c.path).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let size = c.size;
|
||||
let hash = c.hash.as_deref();
|
||||
let named: Vec<&String> = by_name
|
||||
.get(&name)
|
||||
.map(|c| c.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
|
||||
.map(|v| v.iter().filter(|f| !used.contains(*f) && (size == 0 || size_of(f) == Some(size))).collect())
|
||||
.unwrap_or_default();
|
||||
// 1. same name and size, confirmed by the content hash
|
||||
let mut hit: Option<(String, &str)> =
|
||||
hash.as_deref().and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), "name"));
|
||||
let mut hit: Option<(String, MatchBy)> =
|
||||
hash.and_then(|h| named.iter().find(|f| hash_of(f).as_deref() == Some(h))).map(|f| (f.to_string(), MatchBy::Name));
|
||||
// 2. renamed: same size and content
|
||||
if hit.is_none()
|
||||
&& let Some(h) = &hash
|
||||
&& let Some(h) = hash
|
||||
&& size > 0
|
||||
{
|
||||
let sizes = by_size.get_or_insert_with(|| {
|
||||
@@ -162,36 +265,75 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
hit = sizes
|
||||
.get(&size)
|
||||
.and_then(|c| c.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h.as_str())))
|
||||
.map(|f| (f.clone(), "content"));
|
||||
.and_then(|v| v.iter().find(|f| !used.contains(*f) && hash_of(f).as_deref() == Some(h)))
|
||||
.map(|f| (f.clone(), MatchBy::Content));
|
||||
}
|
||||
// 3. same name and size only (no hash, or the file was edited in place): one candidate
|
||||
// is taken, several are ambiguous and skipped rather than guessed
|
||||
if hit.is_none() {
|
||||
match named.len() {
|
||||
1 => hit = Some((named[0].clone(), "name")),
|
||||
0 => {}
|
||||
match named.as_slice() {
|
||||
[one] => hit = Some(((*one).clone(), MatchBy::Name)),
|
||||
[] => {}
|
||||
_ => {
|
||||
ambiguous.push(json!({"id": id.0, "from": old, "candidates": named}));
|
||||
plan.ambiguous.push(AmbiguousFile { id: c.id, from: c.path.clone(), candidates: named.iter().map(|f| (*f).clone()).collect() });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((path, by)) = hit else { continue };
|
||||
ops.push(relink_op(*id, &path));
|
||||
found.push(json!({"id": id.0, "from": old, "to": path, "by": by}));
|
||||
used.insert(path);
|
||||
used.insert(path.clone());
|
||||
plan.found.push(FoundFile { id: c.id, from: c.path.clone(), to: path, by });
|
||||
}
|
||||
let still = lost.len() - found.len();
|
||||
if !ops.is_empty() {
|
||||
s.commit("Find Missing Photos", Op::Batch { ops })?;
|
||||
plan.missing = lost.len() - plan.found.len();
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Relink what [`plan_find_missing`] found, in one undoable step, re-checked against the
|
||||
/// catalog as it is now (the search may have run on a worker while the library changed): a
|
||||
/// photo that no longer points at the path it was missing from (relinked, removed) is left
|
||||
/// alone, and a file that is now some photo's original — or that the plan names twice — is not
|
||||
/// used (that photo stays missing). `missing` is the plan's count, corrected for what changed
|
||||
/// among the photos the plan names.
|
||||
fn commit_find(s: &mut Session, plan: FindPlan) -> Result<Value> {
|
||||
let still_at = |cat: &Catalog, id: PhotoId, from: &str| cat.photo(id).and_then(|p| checked_path(p)) == Some(from);
|
||||
let mut in_use: HashSet<String> = candidates(&s.catalog).into_iter().collect();
|
||||
let mut found = Vec::new();
|
||||
let mut missing = plan.missing;
|
||||
for f in plan.found {
|
||||
if !still_at(&s.catalog, f.id, &f.from) {
|
||||
continue;
|
||||
}
|
||||
if in_use.contains(&f.to) {
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
in_use.insert(f.to.clone());
|
||||
found.push(f);
|
||||
}
|
||||
let planned = plan.ambiguous.len();
|
||||
let ambiguous: Vec<AmbiguousFile> = plan.ambiguous.into_iter().filter(|a| still_at(&s.catalog, a.id, &a.from)).collect();
|
||||
// ambiguous photos relinked meanwhile aren't missing any more
|
||||
missing = missing.saturating_sub(planned - ambiguous.len());
|
||||
if !found.is_empty() {
|
||||
s.commit("Find Missing Photos", Op::Batch { ops: found.iter().map(|f| relink_op(f.id, &f.to)).collect() })?;
|
||||
for f in &found {
|
||||
if let Some(id) = f["id"].as_u64() {
|
||||
s.media.forget(PhotoId(id));
|
||||
}
|
||||
s.media.forget(f.id);
|
||||
}
|
||||
}
|
||||
Ok(json!({"found": found, "missing": still, "ambiguous": ambiguous}))
|
||||
Ok(FindPlan { found, missing, ambiguous }.to_json())
|
||||
}
|
||||
|
||||
/// Search `folder` (recursively) for each missing photo's file ([`plan_find_missing`]) and
|
||||
/// relink every match in one undoable step. With `found` instead (what a search on a worker
|
||||
/// thread returned, as `{found, missing, ambiguous}`), only the relinking is done.
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findMissing";
|
||||
if p.get("found").is_some_and(Value::is_array) {
|
||||
return commit_find(s, FindPlan::from_json(p));
|
||||
}
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing `folder`"))?;
|
||||
let plan = plan_find_missing(&find_candidates(&s.catalog), folder).map_err(|e| bad(C, e))?;
|
||||
commit_find(s, plan)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -213,7 +355,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Find Missing Photos",
|
||||
[],
|
||||
None,
|
||||
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
|
||||
"{folder} — relink every missing photo whose file is somewhere in the folder: same name and size (and content, when the library knows its hash), or — renamed — same size and content; or {found, missing, ambiguous} — relink what a search already found (the app searches on a worker thread; photos relinked meanwhile and files now in use are skipped) → {found: [{id, from, to, by: name|content}], missing, ambiguous: [{id, from, candidates}] (several same-name candidates and no hash to tell them apart: skipped)}",
|
||||
always,
|
||||
find_missing
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
|
||||
|
||||
mod before;
|
||||
mod browse;
|
||||
pub(crate) mod browse;
|
||||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
@@ -15,7 +15,7 @@ mod edit;
|
||||
mod export;
|
||||
pub mod filters;
|
||||
pub mod keywords;
|
||||
mod library;
|
||||
pub mod library;
|
||||
pub mod lut_profiles;
|
||||
pub mod manage;
|
||||
mod masks;
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct PreviewBuild {
|
||||
pub failed: AtomicUsize,
|
||||
pub cancel: AtomicBool,
|
||||
pub finished: AtomicBool,
|
||||
/// Damaged smart previews rebuilt (Build Smart Previews only).
|
||||
pub repaired: AtomicUsize,
|
||||
/// What is built ("" = previews; "smart previews" — Build Smart Previews runs the same way).
|
||||
pub what: &'static str,
|
||||
/// Why the whole build failed (e.g. the smart previews folder isn't there).
|
||||
pub error: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl PreviewBuild {
|
||||
@@ -33,8 +39,15 @@ impl PreviewBuild {
|
||||
"failed": self.failed.load(Ordering::Relaxed),
|
||||
"running": !self.finished.load(Ordering::Relaxed),
|
||||
"cancelled": self.cancel.load(Ordering::Relaxed),
|
||||
"what": if self.what.is_empty() { "previews" } else { self.what },
|
||||
"repaired": self.repaired.load(Ordering::Relaxed),
|
||||
"error": self.error(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
|
||||
@@ -94,56 +107,134 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state.json())
|
||||
}
|
||||
|
||||
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
|
||||
/// how to load the original at preview size).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>);
|
||||
|
||||
/// What Build / Discard Smart Previews did.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct SmartCounts {
|
||||
/// Proxies there now (complete ones kept, missing or damaged ones written).
|
||||
built: usize,
|
||||
/// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt.
|
||||
repaired: usize,
|
||||
removed: usize,
|
||||
/// `[[id, why]]`
|
||||
failed: Vec<Value>,
|
||||
}
|
||||
|
||||
/// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app).
|
||||
/// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never
|
||||
/// recreated. With `state`, progress and cancel.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart_run(
|
||||
dir: &std::path::Path,
|
||||
custom: bool,
|
||||
discard: bool,
|
||||
jobs: Vec<SmartJob>,
|
||||
state: Option<&PreviewBuild>,
|
||||
) -> std::result::Result<SmartCounts, String> {
|
||||
let mut n = SmartCounts::default();
|
||||
if !discard {
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if custom {
|
||||
crate::smart::check_writable(dir)?;
|
||||
} else {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
for (id, path, source) in jobs {
|
||||
if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) {
|
||||
break;
|
||||
}
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
n.removed += 1;
|
||||
}
|
||||
} else {
|
||||
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
|
||||
// drive) is (re)built
|
||||
let damaged = path.exists();
|
||||
if damaged && crate::smart::is_valid(&path) {
|
||||
n.built += 1;
|
||||
} else {
|
||||
// atomic: a failed write leaves no partial proxy that would pass for a built one
|
||||
let r = source
|
||||
.ok_or_else(|| "nothing to build from".to_string())
|
||||
.and_then(|s| s.load_source())
|
||||
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
match r {
|
||||
Ok(()) => {
|
||||
n.built += 1;
|
||||
if damaged {
|
||||
n.repaired += 1;
|
||||
if let Some(st) = state {
|
||||
st.repaired.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
n.failed.push(json!([id.0, e]));
|
||||
if let Some(st) = state {
|
||||
st.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(st) = state {
|
||||
st.done.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Smart previews: build (from the original, at preview size) or discard the proxies that keep
|
||||
/// photos editable while their originals are offline.
|
||||
/// photos editable while their originals are offline. With `background` (the app) the files are
|
||||
/// read and written on a worker thread; progress like Build Previews (`library.previewProgress`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.smartPreviews";
|
||||
let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;
|
||||
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new());
|
||||
if !discard {
|
||||
// a chosen folder on another drive must be there: never recreate it on this one
|
||||
if s.smart_previews_dir.is_some() {
|
||||
crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false);
|
||||
if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let mut jobs: Vec<SmartJob> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(crate::smart::file_name(&ph));
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
|
||||
// drive) is (re)built
|
||||
let damaged = path.exists();
|
||||
if damaged && crate::smart::is_valid(&path) {
|
||||
built += 1;
|
||||
continue;
|
||||
}
|
||||
// atomic: a failed write leaves no partial proxy that would pass for a built one
|
||||
let r = s
|
||||
.source_now(id, crate::media::SourceLevel::Preview)
|
||||
.and_then(|src| crate::smart::encode(&src))
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
match r {
|
||||
Ok(()) => {
|
||||
built += 1;
|
||||
repaired += usize::from(damaged);
|
||||
}
|
||||
Err(e) => failed.push(json!([id.0, e])),
|
||||
}
|
||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
||||
jobs.push((id, path, source));
|
||||
}
|
||||
Ok(json!({"built": built, "repaired": repaired, "removed": removed, "failed": failed}))
|
||||
let custom = s.smart_previews_dir.is_some();
|
||||
if background {
|
||||
let what = if discard { "discarding smart previews" } else { "smart previews" };
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-previews".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) {
|
||||
log::warn!("{C}: {e}");
|
||||
*st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
|
||||
}
|
||||
st.finished.store(true, Ordering::Relaxed);
|
||||
})
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
return Ok(state.json());
|
||||
}
|
||||
let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed}))
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
@@ -225,7 +316,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Build Smart Previews",
|
||||
[],
|
||||
None,
|
||||
"{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}",
|
||||
"{ids?, discard?: bool, background?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}; with background (the app's menu) the files are read and written on a worker thread → {total, done, failed, repaired, running, what} like library.buildPreviews (progress: library.previewProgress, stop: library.cancelPreviews)",
|
||||
always,
|
||||
smart
|
||||
),
|
||||
@@ -256,7 +347,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
build
|
||||
),
|
||||
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| {
|
||||
cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, repaired, running, cancelled, what, error} | null", always, |s, _| {
|
||||
Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json()))
|
||||
}),
|
||||
cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| {
|
||||
|
||||
@@ -131,7 +131,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
|
||||
let as_shot_wb = Some((t.round(), tint.round()));
|
||||
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
|
||||
let relative = raw.format == lightcraft_raw::RawFormat::Arw && !lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
|
||||
let embedded_lens = embedded_lens(&raw);
|
||||
return Ok(ProbeInfo {
|
||||
embedded_lens,
|
||||
@@ -221,6 +223,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
|
||||
drop(bytes);
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
|
||||
let lens = embedded_lens(&raw.info());
|
||||
@@ -242,12 +247,10 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||||
let mut stages = vec![("develop", t0.elapsed())];
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
stages.push(("transform", t0.elapsed()));
|
||||
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
|
||||
stages.push(("highlights", t0.elapsed()));
|
||||
let m = t.matrix.to_f32();
|
||||
let m = camera_look.map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
|
||||
let gain = 2f32.powf(t.baseline_exposure as f32);
|
||||
let wb = t.wb;
|
||||
img.map_in_place(|p| {
|
||||
@@ -276,7 +279,12 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||||
}
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
|
||||
let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback;
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
return Ok((
|
||||
img,
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone: camera_look.map(|p| p.tone) },
|
||||
));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
|
||||
drop(bytes);
|
||||
|
||||
+370
-183
@@ -517,205 +517,392 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
|
||||
}
|
||||
|
||||
/// Import files/folders with options (preset, keywords). See the module docs.
|
||||
///
|
||||
/// The file-system half ([`ImportJob::prepare`]: expanding folders, probing, reading sidecars,
|
||||
/// copying / placing files) and the catalog half ([`commit_prepared`]) are separate so the app can
|
||||
/// run the first on a worker thread; here both run in turn.
|
||||
pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> crate::Result<ImportReport> {
|
||||
let mode = opts.mode;
|
||||
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of every
|
||||
// added file in its own storage already, so "copy" there means "add".
|
||||
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
|
||||
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
|
||||
if mode == ImportMode::Move && opts.local {
|
||||
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
// where copies go: the chosen folder, else the library's Originals/
|
||||
let copy_root = opts
|
||||
.destination
|
||||
.as_deref()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
|
||||
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
|
||||
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
|
||||
}
|
||||
let mut seq = opts.rename_start.max(1);
|
||||
let files = expand(paths, lib_dir.as_deref());
|
||||
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
|
||||
let mut job = ImportJob::new(s, opts.clone())?;
|
||||
let prepared = job.prepare(paths, &std::sync::atomic::AtomicBool::new(false));
|
||||
// probes a scan left for files not imported now stay for a later import
|
||||
s.import_probes.extend(std::mem::take(&mut job.cache));
|
||||
commit_prepared(s, &job.opts, &job.now, prepared)
|
||||
}
|
||||
|
||||
// existing paths and content hashes
|
||||
let mut by_path: HashMap<&str, PhotoId> = HashMap::new();
|
||||
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.as_str(), p.id);
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
}
|
||||
let mut todo = Vec::new();
|
||||
// a file that was only browsed (Local) joins the library when it is imported for real
|
||||
let mut promote = Vec::new();
|
||||
for f in files {
|
||||
match by_path.get(f.as_str()) {
|
||||
Some(id) if !opts.local && s.catalog.photo(*id).is_some_and(|p| p.local) => {
|
||||
promote.push(Op::SetLocal { id: *id, local: false });
|
||||
report.imported.push(id.0);
|
||||
/// Everything the file-system half of an import needs, detached from the session so it can run
|
||||
/// on another thread (a network share or a card can take minutes). Built by [`ImportJob::new`];
|
||||
/// [`ImportJob::prepare`] may be called for several batches in turn (renamed copies keep counting,
|
||||
/// duplicates are found across batches); each batch is then [`commit_prepared`] on the session.
|
||||
pub struct ImportJob {
|
||||
pub opts: ImportOptions,
|
||||
/// The mode in effect (Copy / Move become Add in a library without its own folder).
|
||||
mode: ImportMode,
|
||||
lib_dir: Option<PathBuf>,
|
||||
copy_root: Option<PathBuf>,
|
||||
/// path → (photo, a Local browse record?)
|
||||
by_path: HashMap<String, (PhotoId, bool)>,
|
||||
/// content hash → the photo that has it (`None`: a file earlier in this import)
|
||||
by_hash: HashMap<String, Option<PhotoId>>,
|
||||
probe: Option<crate::media::FileProbe>,
|
||||
file_bytes: Option<crate::merge::ByteReader>,
|
||||
/// Probes from a preceding scan (not read again).
|
||||
cache: HashMap<String, ProbeInfo>,
|
||||
naming: crate::sidecar::SidecarNaming,
|
||||
now: String,
|
||||
seq: usize,
|
||||
/// Files found so far (folders expanded) and handled so far, for progress.
|
||||
pub total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
/// The outcome of [`ImportJob::prepare`] for one batch, in file order.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Prepared {
|
||||
pub scanned: usize,
|
||||
items: Vec<PreparedItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PreparedItem {
|
||||
/// A Local (browsed) record imported for real.
|
||||
Promote(PhotoId),
|
||||
Duplicate {
|
||||
path: String,
|
||||
existing: Option<PhotoId>,
|
||||
reason: &'static str,
|
||||
hash: Option<String>,
|
||||
},
|
||||
Failed(String, String),
|
||||
Kept(Kept),
|
||||
Ready(Box<ReadyFile>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ReadyFile {
|
||||
path: String,
|
||||
stored: String,
|
||||
info: ProbeInfo,
|
||||
sidecar: Option<crate::sidecar::SidecarData>,
|
||||
placed: Option<crate::import_move::Placed>,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
/// Undo what the batch did on disk that only a commit would make permanent: files placed by a
|
||||
/// Move (their sources were never touched). Copies stay, like copies of a failed commit.
|
||||
pub fn rollback(&self) {
|
||||
for it in &self.items {
|
||||
if let PreparedItem::Ready(r) = it
|
||||
&& let Some(pl) = &r.placed
|
||||
{
|
||||
crate::import_move::rollback(pl);
|
||||
}
|
||||
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
|
||||
None => todo.push(f),
|
||||
}
|
||||
}
|
||||
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let mut fresh = probe_all(s.media.file_probe.as_ref(), &missing, &ScanProgress::default()).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
let now = (s.clock)();
|
||||
let mut ops = promote;
|
||||
// Move: placed at the destination, sources untouched until the records are committed
|
||||
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
let info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
report.failed.push((path, e));
|
||||
/// Entries in the batch (after expanding folders).
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportJob {
|
||||
/// Snapshot what an import with `opts` needs from the session (no file-system calls). Takes
|
||||
/// the probes a preceding scan left ([`Session::import_probes`]).
|
||||
pub fn new(s: &mut Session, opts: ImportOptions) -> crate::Result<Self> {
|
||||
let mode = opts.mode;
|
||||
// Only a library on disk has an `Originals/` folder. The browser build keeps the bytes of
|
||||
// every added file in its own storage already, so "copy" there means "add".
|
||||
let transfer = matches!(mode, ImportMode::Copy | ImportMode::Move);
|
||||
let mode = if transfer && s.library.as_ref().is_some_and(|l| !l.on_disk) { ImportMode::Add } else { mode };
|
||||
if mode == ImportMode::Move && opts.local {
|
||||
return Err(crate::EngineError::Other("browsing a folder never moves its files".into()));
|
||||
}
|
||||
let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
||||
// where copies go: the chosen folder, else the library's Originals/
|
||||
let copy_root = opts
|
||||
.destination
|
||||
.as_deref()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| lib_dir.as_ref().map(|l| l.join("Originals")));
|
||||
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
|
||||
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
|
||||
}
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
if let Some(h) = &p.content_hash {
|
||||
by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
Ok(ImportJob {
|
||||
seq: opts.rename_start.max(1),
|
||||
opts,
|
||||
mode,
|
||||
lib_dir,
|
||||
copy_root,
|
||||
by_path,
|
||||
by_hash,
|
||||
probe: s.media.file_probe.clone(),
|
||||
file_bytes: s.media.file_bytes.clone(),
|
||||
cache: std::mem::take(&mut s.import_probes),
|
||||
naming: s.xmp.naming,
|
||||
now: (s.clock)(),
|
||||
total: Default::default(),
|
||||
done: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The file-system half of importing `paths`: expand folders, skip known paths, probe, skip
|
||||
/// duplicates by content, read sidecars, copy or place (Move) the files. Nothing in the
|
||||
/// catalog changes. Stops before the next file once `cancel` is set.
|
||||
pub fn prepare(&mut self, paths: &[String], cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
let files = self.expand(paths);
|
||||
self.prepare_files(files, cancel)
|
||||
}
|
||||
|
||||
/// Expand `paths` (folders recursively, the library's own folder skipped) into the files an
|
||||
/// import would handle; counted in [`ImportJob::total`].
|
||||
pub fn expand(&self, paths: &[String]) -> Vec<String> {
|
||||
let files = expand(paths, self.lib_dir.as_deref());
|
||||
self.total.fetch_add(files.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
files
|
||||
}
|
||||
|
||||
/// When the import started ([`Session::clock`]), for [`commit_prepared`].
|
||||
pub fn now(&self) -> &str {
|
||||
&self.now
|
||||
}
|
||||
|
||||
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
|
||||
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let opts = self.opts.clone();
|
||||
let mode = self.mode;
|
||||
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
|
||||
let mut todo = Vec::new();
|
||||
for f in files {
|
||||
match self.by_path.get(f.as_str()).copied() {
|
||||
Some((id, true)) if !opts.local => {
|
||||
// a file that was only browsed (Local) joins the library when it is imported for real
|
||||
self.by_path.insert(f, (id, false));
|
||||
out.items.push(PreparedItem::Promote(id));
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
}
|
||||
Some((id, _)) => {
|
||||
out.items.push(PreparedItem::Duplicate { path: f, existing: Some(id), reason: "path", hash: None });
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
}
|
||||
None => todo.push(f),
|
||||
}
|
||||
}
|
||||
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| self.cache.remove(f)).collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let progress = ScanProgress::default();
|
||||
let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
for (path, info) in todo.into_iter().zip(probed) {
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
}
|
||||
self.done.fetch_add(1, Relaxed);
|
||||
let mut info = match info {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: {e}");
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(existing) = self.by_hash.get(h)
|
||||
{
|
||||
out.items.push(PreparedItem::Duplicate { path, existing: *existing, reason: "content", hash: Some(h.clone()) });
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(h) = &info.content_hash
|
||||
&& !opts.local
|
||||
&& let Some(id) = by_hash.get(h)
|
||||
{
|
||||
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
|
||||
continue;
|
||||
}
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) {
|
||||
Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) {
|
||||
Ok(sc) => Some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
});
|
||||
let mut info = info;
|
||||
if info.captured.is_none() {
|
||||
info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone());
|
||||
}
|
||||
let stored = match (mode, ©_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
&& !lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
|
||||
let mut placed = None;
|
||||
let stored = match (mode, &self.copy_root) {
|
||||
(ImportMode::Copy | ImportMode::Move, Some(root))
|
||||
if !crate::import_move::inside(Path::new(&path), root)
|
||||
&& !self.lib_dir.as_ref().is_some_and(|l| crate::import_move::inside(Path::new(&path), l)) =>
|
||||
{
|
||||
// the templates see the photo as it will be catalogued (undated: the import time)
|
||||
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &self.now);
|
||||
q.captured = info.captured.clone();
|
||||
q.meta = info.meta.clone();
|
||||
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
|
||||
let n = crate::rename::expand(t, &q, self.seq);
|
||||
self.seq += 1;
|
||||
n
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed = Some(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.items.push(PreparedItem::Failed(path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
out.items.push(PreparedItem::Kept(Kept { path: path.clone(), reason: reason.into() }));
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
if let Some(h) = &info.content_hash {
|
||||
self.by_hash.insert(h.clone(), None);
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng
|
||||
&& mode == ImportMode::Copy
|
||||
&& stored != path
|
||||
&& info.kind == MediaKind::Raw
|
||||
&& !info.format.eq_ignore_ascii_case("DNG")
|
||||
{
|
||||
// the templates see the photo as it will be catalogued (undated: the import time)
|
||||
let own = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut q = Photo::new(PhotoId(0), Source::File { path: path.clone() }, &own, &info.format, 0, 0, &now);
|
||||
q.captured = info.captured.clone();
|
||||
q.meta = info.meta.clone();
|
||||
let name = opts.rename.as_deref().filter(|t| !t.trim().is_empty()).map(|t| {
|
||||
let n = crate::rename::expand(t, &q, seq);
|
||||
seq += 1;
|
||||
n
|
||||
match crate::cmd::convert::write_dng_with(self.file_bytes.as_ref(), &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
}
|
||||
}
|
||||
out.items.push(PreparedItem::Ready(Box::new(ReadyFile { path, stored, info, sidecar, placed })));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog half of an import: add the photos [`ImportJob::prepare`] readied (one undoable op;
|
||||
/// a Move's sources are removed once it is saved). On a failed commit a Move's placed files are
|
||||
/// taken back.
|
||||
pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepared: Prepared) -> crate::Result<ImportReport> {
|
||||
let now = now.to_string();
|
||||
let mut report = ImportReport { scanned: prepared.scanned, ..Default::default() };
|
||||
let mut ops = Vec::new();
|
||||
// Move: placed at the destination, sources untouched until the records are committed
|
||||
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
|
||||
// photos added by this batch, by content (a duplicate of a file earlier in the import names it)
|
||||
let mut new_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
for it in prepared.items {
|
||||
match it {
|
||||
PreparedItem::Promote(id) => {
|
||||
if s.catalog.photo(id).is_some_and(|p| p.local) {
|
||||
ops.push(Op::SetLocal { id, local: false });
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
}
|
||||
PreparedItem::Duplicate { path, existing, reason, hash } => {
|
||||
let existing = existing.or_else(|| {
|
||||
let h = hash?;
|
||||
new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id))
|
||||
});
|
||||
let folders = opts.organize.folders(&q);
|
||||
if mode == ImportMode::Move && !crate::import_move::is_symlink(Path::new(&path)) {
|
||||
let dir = folders.iter().fold(root.to_path_buf(), |d, f| d.join(f));
|
||||
let name = name.unwrap_or(own);
|
||||
match crate::import_move::place(Path::new(&path), &dir, &name) {
|
||||
Ok(pl) => {
|
||||
let dst = pl.dst.to_string_lossy().to_string();
|
||||
placed.push(pl);
|
||||
dst
|
||||
}
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mode == ImportMode::Move {
|
||||
let reason = "a symbolic link: the file it points to was copied, the link and its target stay";
|
||||
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
|
||||
}
|
||||
match copy_into(root, &path, &folders, name.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.failed.push((path, e));
|
||||
continue;
|
||||
}
|
||||
report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason });
|
||||
}
|
||||
PreparedItem::Failed(path, e) => report.failed.push((path, e)),
|
||||
PreparedItem::Kept(k) => report.kept.push(k),
|
||||
PreparedItem::Ready(r) => {
|
||||
let ReadyFile { path, stored, info, sidecar, placed: pl } = *r;
|
||||
placed.extend(pl);
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
if let Some(h) = &info.content_hash {
|
||||
new_hash.insert(h.clone(), id);
|
||||
}
|
||||
let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default());
|
||||
// a copy is catalogued under its new name
|
||||
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
|
||||
p.kind = info.kind;
|
||||
p.file_size = info.file_size;
|
||||
p.captured = info.captured;
|
||||
p.meta = info.meta;
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
apply_import_defaults(s, &mut p);
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
}
|
||||
for k in &opts.keywords {
|
||||
let k = lightcraft_catalog::keywords::clean(k);
|
||||
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
p.meta.keywords.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
let reason = "already inside the destination or the library: added where it is";
|
||||
report.kept.push(Kept { path: path.clone(), reason: reason.into() });
|
||||
path.clone()
|
||||
}
|
||||
_ => path.clone(),
|
||||
};
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
if let Some(h) = &info.content_hash {
|
||||
by_hash.insert(h.clone(), id);
|
||||
}
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let mut stored = stored;
|
||||
if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") {
|
||||
match crate::cmd::convert::write_dng_for(s, &stored, String::new()) {
|
||||
Ok(dng) => {
|
||||
let _ = std::fs::remove_file(&stored);
|
||||
stored = dng;
|
||||
info.format = "DNG".into();
|
||||
if let Some(preset) = &opts.preset {
|
||||
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
|
||||
let label = format!("Preset: {}", preset.name);
|
||||
p.develop = d.clone();
|
||||
p.edited = Some(now.clone());
|
||||
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
|
||||
}
|
||||
Err(e) => log::warn!("import {path}: copy as DNG: {e}"),
|
||||
report.imported.push(id.0);
|
||||
p.local = opts.local;
|
||||
if opts.local {
|
||||
// the state as browsed: a later change keeps the record from being forgotten
|
||||
p.set_local_baseline();
|
||||
}
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
}
|
||||
// a copy is catalogued under its new name
|
||||
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
|
||||
p.kind = info.kind;
|
||||
p.file_size = info.file_size;
|
||||
p.captured = info.captured;
|
||||
p.meta = info.meta;
|
||||
p.as_shot_wb = info.as_shot_wb;
|
||||
p.content_hash = info.content_hash;
|
||||
p.embedded_lens = info.embedded_lens;
|
||||
p.preview_only = info.preview_only.clone();
|
||||
apply_import_defaults(s, &mut p);
|
||||
if let Some(sc) = &sidecar {
|
||||
crate::sidecar::merge_into(&mut p, sc, &now);
|
||||
report.sidecars += 1;
|
||||
}
|
||||
if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) {
|
||||
crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
|
||||
}
|
||||
for k in &opts.keywords {
|
||||
let k = lightcraft_catalog::keywords::clean(k);
|
||||
if !k.is_empty() && !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
p.meta.keywords.push(k);
|
||||
}
|
||||
}
|
||||
if let Some(preset) = &opts.preset {
|
||||
let d = std::sync::Arc::new(preset.apply(&p.develop, 1.0));
|
||||
let label = format!("Preset: {}", preset.name);
|
||||
p.develop = d.clone();
|
||||
p.edited = Some(now.clone());
|
||||
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
|
||||
}
|
||||
report.imported.push(id.0);
|
||||
p.local = opts.local;
|
||||
if opts.local {
|
||||
// the state as browsed: a later change keeps the record from being forgotten
|
||||
p.set_local_baseline();
|
||||
}
|
||||
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
||||
}
|
||||
let log0 = s.pending_log.len();
|
||||
if !ops.is_empty()
|
||||
|
||||
+89
-16
@@ -10,6 +10,8 @@
|
||||
//! run them off the UI thread.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod availability;
|
||||
mod camera_preview;
|
||||
pub mod cmd;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
@@ -57,6 +59,9 @@ pub enum EngineError {
|
||||
/// be written. They stay queued and are written by the next successful save.
|
||||
#[error("saved in memory but not written to disk: {0}; LightCraft will retry")]
|
||||
NotSaved(String),
|
||||
/// Another process (the app, `lightcraft-cli`, another computer) has the library open.
|
||||
#[error("{0}")]
|
||||
LibraryInUse(String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
@@ -68,6 +73,22 @@ pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
pub struct UndoEntry {
|
||||
pub label: String,
|
||||
pub op: Op,
|
||||
/// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder
|
||||
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists.
|
||||
pub folder: Option<FolderMove>,
|
||||
}
|
||||
|
||||
/// A folder renamed or moved on disk as part of an undo step.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FolderMove {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl FolderMove {
|
||||
fn reversed(&self) -> Self {
|
||||
Self { from: self.to.clone(), to: self.from.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-progress slider drag / brush stroke: one undo step when it ends.
|
||||
@@ -253,11 +274,22 @@ impl Session {
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
|
||||
}
|
||||
|
||||
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
|
||||
/// panic guard, auto versions, XMP sidecar auto-write and the durable save (a failed save is
|
||||
/// `NotSaved`). Not journaled. The app's import task commits its batches this way.
|
||||
pub fn execute_fn(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
self.run_command(id, None, f)
|
||||
}
|
||||
|
||||
fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let log_start = self.pending_log.len();
|
||||
let was_active = self.active();
|
||||
self.depth += 1;
|
||||
// last-resort guard: a panic in a command is that command's error, not a crash
|
||||
let r = guard::catch(&format!("`{id}`"), || (spec.run)(self, params)).unwrap_or_else(|e| Err(EngineError::Other(e)));
|
||||
let r = guard::catch(&format!("`{id}`"), || f(self)).unwrap_or_else(|e| Err(EngineError::Other(e)));
|
||||
self.depth -= 1;
|
||||
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
|
||||
self.previous_active = was_active;
|
||||
@@ -265,7 +297,10 @@ impl Session {
|
||||
self.auto_version(left);
|
||||
}
|
||||
}
|
||||
if r.is_ok() && spec.journal && self.depth == 0 {
|
||||
if r.is_ok()
|
||||
&& let Some(params) = journal
|
||||
&& self.depth == 0
|
||||
{
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..1000);
|
||||
@@ -302,7 +337,7 @@ impl Session {
|
||||
let fwd = op.clone();
|
||||
let inv = self.catalog.apply(op)?;
|
||||
self.pending_log.push(fwd);
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv });
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None });
|
||||
if self.undo.len() > 1000 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
@@ -310,6 +345,16 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Session::commit`] for an op that goes with a folder already renamed on disk; the undo
|
||||
/// step renames it back (`folder` is the undo direction: current place → old place).
|
||||
pub(crate) fn commit_with_folder(&mut self, label: &str, op: Op, folder: FolderMove) -> Result<()> {
|
||||
self.commit(label, op)?;
|
||||
if let Some(e) = self.undo.last_mut() {
|
||||
e.folder = Some(folder);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
|
||||
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
|
||||
/// Saved with the library but not an undo step.
|
||||
@@ -340,9 +385,13 @@ impl Session {
|
||||
if n < 2 || n > self.undo.len() {
|
||||
return;
|
||||
}
|
||||
// a step that moves a folder on disk stays on its own
|
||||
if self.undo[self.undo.len() - n..].iter().any(|e| e.folder.is_some()) {
|
||||
return;
|
||||
}
|
||||
let tail = self.undo.split_off(self.undo.len() - n);
|
||||
let ops = tail.into_iter().rev().map(|e| e.op).collect();
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } });
|
||||
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops }, folder: None });
|
||||
}
|
||||
|
||||
/// Apply without recording undo (interactive previews).
|
||||
@@ -353,7 +402,7 @@ impl Session {
|
||||
|
||||
pub fn undo_step(&mut self) -> Result<String> {
|
||||
let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let redo = match self.apply_with_files(&e.op) {
|
||||
let redo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
self.undo.push(e);
|
||||
@@ -361,13 +410,13 @@ impl Session {
|
||||
}
|
||||
};
|
||||
self.pending_log.push(e.op);
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo });
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
pub fn redo_step(&mut self) -> Result<String> {
|
||||
let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let undo = match self.apply_with_files(&e.op) {
|
||||
let undo = match self.apply_with_files(&e.op, e.folder.as_ref()) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
self.redo.push(e);
|
||||
@@ -375,26 +424,44 @@ impl Session {
|
||||
}
|
||||
};
|
||||
self.pending_log.push(e.op);
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo });
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
/// Apply an undo/redo op, first moving the files its renames imply (all or nothing). Files
|
||||
/// that can't be moved back after a failure are reported and the library follows them (a
|
||||
/// logged change outside the undo history), so none goes missing.
|
||||
fn apply_with_files(&mut self, op: &Op) -> Result<Op> {
|
||||
/// Apply an undo/redo op, first renaming the step's folder and moving the files its renames
|
||||
/// imply (all or nothing). Files that can't be moved back after a failure are reported and the
|
||||
/// library follows them (a logged change outside the undo history), so none goes missing; the
|
||||
/// folder is only moved back when no file was left behind at its new path.
|
||||
fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result<Op> {
|
||||
let fs = rename::RealFs;
|
||||
if let Some(f) = folder {
|
||||
cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
|
||||
}
|
||||
let undo_folder = || {
|
||||
if let Some(f) = folder {
|
||||
let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from);
|
||||
}
|
||||
};
|
||||
let moves = self.file_moves(op);
|
||||
if let Err(e) = rename::move_all(&fs, &moves) {
|
||||
if e.stuck.is_empty() {
|
||||
undo_folder();
|
||||
}
|
||||
return Err(EngineError::Other(format!("can't move the files back: {}", self.follow_stuck(op, e, false))));
|
||||
}
|
||||
match self.catalog.apply(op.clone()) {
|
||||
Ok(inv) => Ok(inv),
|
||||
Ok(inv) => {
|
||||
if let Some(f) = folder {
|
||||
cmd::browse::follow_folder(self, &f.from, &f.to);
|
||||
}
|
||||
Ok(inv)
|
||||
}
|
||||
Err(e) => {
|
||||
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
|
||||
if let Err(be) = rename::move_all(&fs, &back) {
|
||||
return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message)));
|
||||
}
|
||||
undo_folder();
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
@@ -509,7 +576,11 @@ impl Session {
|
||||
|
||||
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
|
||||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
|
||||
// the view fills in as the background checks find files gone
|
||||
key.1.push_str(&format!("|{}", self.media.availability.generation()));
|
||||
}
|
||||
if self.visible_key.as_ref() != Some(&key) {
|
||||
// "in the last N days" rules count back from the session's clock
|
||||
lightcraft_catalog::rules::set_now(Some((self.clock)()));
|
||||
@@ -544,8 +615,8 @@ impl Session {
|
||||
}
|
||||
if self.source == LibrarySource::Missing {
|
||||
// only the photos the query kept (library photos, not Local browse records) are checked
|
||||
let cat = &self.catalog;
|
||||
visible.retain(|id| cmd::missing::is_missing(cat, *id));
|
||||
let (cat, avail) = (&self.catalog, &self.media.availability);
|
||||
visible.retain(|id| cmd::missing::is_missing(cat, avail, *id));
|
||||
}
|
||||
if self.source != LibrarySource::RecentlyDeleted {
|
||||
visible = self.catalog.arrange_stacks(&visible);
|
||||
@@ -642,6 +713,8 @@ mod tests_persist;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_xmp;
|
||||
+127
-14
@@ -23,7 +23,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_catalog::{FsStore, Journal, LoadReport, Store};
|
||||
use lightcraft_catalog::{FsStore, Journal, LibraryLock, LoadReport, Store};
|
||||
use lightcraft_develop::Preset;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -64,6 +64,15 @@ pub struct Library {
|
||||
pub forgot_local: Option<lightcraft_catalog::ForgetPlan>,
|
||||
presets_written: String,
|
||||
view_written: Vec<u8>,
|
||||
/// Settings files that were unreadable or damaged when the library opened (`library.info` →
|
||||
/// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]).
|
||||
pub settings_warnings: Vec<String>,
|
||||
warnings_reported: usize,
|
||||
/// Settings files that couldn't be read or set aside: never overwritten this session.
|
||||
blocked: Vec<&'static str>,
|
||||
/// Keeps other processes out of this library while it's open (last: released after the
|
||||
/// journal's background snapshot has landed).
|
||||
lock: Option<LibraryLock>,
|
||||
}
|
||||
|
||||
/// Where a library's files live (see [`Session::open_library_in`]).
|
||||
@@ -171,23 +180,112 @@ fn poll_compaction(lib: &mut Library) {
|
||||
/// How long the frame loop waits before retrying a failed append (commands retry at once).
|
||||
const RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(store: &mut dyn Store, name: &str) -> Option<T> {
|
||||
store.read(name).ok().flatten().and_then(|b| serde_json::from_slice(&b).ok())
|
||||
/// `a` and `b` name the same directory.
|
||||
fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// This program, for the lock owner note ("LightCraft", "lightcraft-cli").
|
||||
fn program_name() -> String {
|
||||
let exe = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()));
|
||||
match exe.as_deref() {
|
||||
Some("lightcraft") | None => "LightCraft".into(),
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Problems reading the settings files (`presets.json`, `prefs.json`, `view.json`) when the library
|
||||
/// opened (issue #103). A missing file is not a problem (defaults). A file that doesn't parse is
|
||||
/// kept as `<name>.corrupt-<unix time>` before the defaults are used (so the next save can't
|
||||
/// lose it); a file that can't be read at all (locked by another program, I/O error) is never
|
||||
/// written this session, so its content survives until the library is opened again.
|
||||
#[derive(Default)]
|
||||
struct SettingsLoad {
|
||||
warnings: Vec<String>,
|
||||
/// Files not to overwrite this session.
|
||||
blocked: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl SettingsLoad {
|
||||
fn read<T: serde::de::DeserializeOwned>(&mut self, store: &mut dyn Store, name: &'static str) -> Option<T> {
|
||||
let bytes = match store.read(name) {
|
||||
Ok(b) => b?,
|
||||
Err(e) => {
|
||||
log::error!("library: {name}: {e}");
|
||||
self.blocked.push(name);
|
||||
self.warnings.push(format!(
|
||||
"{name} couldn't be read ({e}). LightCraft uses the defaults for now and won't overwrite the file; reopen the library to try again."
|
||||
));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let err = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) => e,
|
||||
};
|
||||
let secs = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = format!("{name}.corrupt-{secs}");
|
||||
log::error!("library: {name} is damaged: {err}");
|
||||
match store.write_atomic(&keep, &bytes) {
|
||||
Ok(()) => self.warnings.push(format!("{name} is damaged ({err}). It was kept as {keep}, and the defaults are used.")),
|
||||
Err(w) => {
|
||||
self.blocked.push(name);
|
||||
self.warnings.push(format!(
|
||||
"{name} is damaged ({err}) and couldn't be set aside ({w}). LightCraft uses the defaults and won't overwrite the file."
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Open (or create) the library at `dir` into this session, replacing its catalog. With
|
||||
/// `seed_demo`, a newly created library starts with the procedural demo photos.
|
||||
///
|
||||
/// The library is locked for this session ([`lightcraft_catalog::lock`]): if another process
|
||||
/// has it open, this fails with [`EngineError::LibraryInUse`] and nothing is read or changed.
|
||||
/// Reopening the library this session already has open keeps its lock.
|
||||
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())));
|
||||
let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() };
|
||||
self.open_library_in(stores, seed_demo)
|
||||
// the library open in this session (same directory): hand its lock over
|
||||
let reused = self.library.as_mut().filter(|l| same_dir(&l.dir, &dir)).and_then(|l| l.lock.take());
|
||||
let reusing = reused.is_some();
|
||||
let mut lock = match reused {
|
||||
Some(l) => Some(l),
|
||||
None => Some(LibraryLock::acquire(&dir, &program_name()).map_err(|e| EngineError::LibraryInUse(e.to_string()))?),
|
||||
};
|
||||
if let Err(e) = self.open_stores(stores, seed_demo, &mut lock) {
|
||||
if reusing && let Some(lib) = self.library.as_mut() {
|
||||
lib.lock = lock.take();
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.loaded_report()
|
||||
}
|
||||
|
||||
/// Open (or create) a library whose files live in `stores` (e.g. browser storage), replacing
|
||||
/// this session's catalog.
|
||||
/// this session's catalog. Not locked: the host guards against a second opener (see
|
||||
/// [`Session::open_library`] for directories).
|
||||
pub fn open_library_in(&mut self, stores: LibraryStores, seed_demo: bool) -> Result<&LoadReport> {
|
||||
self.open_stores(stores, seed_demo, &mut None)?;
|
||||
self.loaded_report()
|
||||
}
|
||||
|
||||
fn loaded_report(&self) -> Result<&LoadReport> {
|
||||
match &self.library {
|
||||
Some(lib) => Ok(&lib.report),
|
||||
None => Err(EngineError::Other("library closed while opening".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `stores`; the new library takes `lock` once nothing can fail any more.
|
||||
fn open_stores(&mut self, stores: LibraryStores, seed_demo: bool, lock: &mut Option<LibraryLock>) -> Result<()> {
|
||||
let LibraryStores { dir, catalog, mut files, on_disk } = stores;
|
||||
self.media.smart_dir = on_disk.then(|| crate::smart::dir(&dir));
|
||||
// the current library may be these same files: let its background snapshot land first
|
||||
@@ -208,8 +306,9 @@ impl Session {
|
||||
crate::demo::load(self);
|
||||
journal.snapshot(&self.catalog)?;
|
||||
}
|
||||
let mut settings = SettingsLoad::default();
|
||||
// presets
|
||||
if let Some(f) = read_json::<PresetsFile>(files.as_mut(), "presets.json") {
|
||||
if let Some(f) = settings.read::<PresetsFile>(files.as_mut(), "presets.json") {
|
||||
for p in &mut self.presets {
|
||||
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
|
||||
}
|
||||
@@ -223,7 +322,7 @@ impl Session {
|
||||
self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect();
|
||||
}
|
||||
// preferences
|
||||
let prefs = read_json::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.export_presets = prefs.export_presets;
|
||||
@@ -244,7 +343,7 @@ impl Session {
|
||||
self.media.smart_dir = Some(d.clone());
|
||||
}
|
||||
// view state
|
||||
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
|
||||
if let Some(v) = settings.read::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
self.browse = v.browse;
|
||||
self.filter = v.filter;
|
||||
@@ -277,6 +376,10 @@ impl Session {
|
||||
forgot_local: None,
|
||||
presets_written,
|
||||
view_written,
|
||||
settings_warnings: settings.warnings,
|
||||
warnings_reported: 0,
|
||||
blocked: settings.blocked,
|
||||
lock: lock.take(),
|
||||
});
|
||||
// forget untouched Local records of folders not browsed for a while (journaled at once)
|
||||
if self.forget_local_days > 0 {
|
||||
@@ -290,10 +393,7 @@ impl Session {
|
||||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
match &self.library {
|
||||
Some(lib) => Ok(&lib.report),
|
||||
None => Err(EngineError::Other("library closed while opening".into())),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
|
||||
@@ -332,7 +432,7 @@ impl Session {
|
||||
}
|
||||
let presets = presets_json(self);
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if presets != lib.presets_written {
|
||||
if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") {
|
||||
if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
|
||||
log::error!("library: presets: {e}");
|
||||
} else {
|
||||
@@ -413,7 +513,7 @@ impl Session {
|
||||
}
|
||||
let v = self.view_json();
|
||||
let Some(lib) = self.library.as_mut() else { return };
|
||||
if v != lib.view_written {
|
||||
if v != lib.view_written && !lib.blocked.contains(&"view.json") {
|
||||
match lib.files.write_atomic("view.json", &v) {
|
||||
Ok(()) => lib.view_written = v,
|
||||
Err(e) => log::error!("library: view: {e}"),
|
||||
@@ -442,9 +542,22 @@ impl Session {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if lib.blocked.contains(&"prefs.json") {
|
||||
return Err(EngineError::Other(
|
||||
"prefs: prefs.json couldn't be read when the library opened, so it isn't overwritten; reopen the library to save preferences".into(),
|
||||
));
|
||||
}
|
||||
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
|
||||
}
|
||||
|
||||
/// Settings-file warnings of the open library not handed out yet (the UI shows each once).
|
||||
pub fn take_library_warnings(&mut self) -> Vec<String> {
|
||||
let Some(lib) = self.library.as_mut() else { return vec![] };
|
||||
let new = lib.settings_warnings[lib.warnings_reported..].to_vec();
|
||||
lib.warnings_reported = lib.settings_warnings.len();
|
||||
new
|
||||
}
|
||||
|
||||
/// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default).
|
||||
pub fn cache_bytes(&self) -> u64 {
|
||||
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
|
||||
|
||||
+164
-41
@@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 4;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 6;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
@@ -71,9 +71,31 @@ pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), St
|
||||
/// `max_edge` (set by the app). `None`: no usable preview.
|
||||
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
|
||||
|
||||
/// Pixels and the source interpretation learned while decoding; they must be cached together.
|
||||
#[derive(Clone)]
|
||||
pub struct DecodedSource {
|
||||
pub image: Arc<Rgb32f>,
|
||||
pub info: Option<SourceInfo>,
|
||||
/// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the
|
||||
/// catalog's header facts lack (used when `info` is `None`).
|
||||
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
|
||||
}
|
||||
|
||||
impl DecodedSource {
|
||||
pub fn new(image: Arc<Rgb32f>, info: Option<SourceInfo>) -> Self {
|
||||
DecodedSource { image, info, camera_tone: None }
|
||||
}
|
||||
|
||||
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
|
||||
/// with any stored camera tone curve.
|
||||
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
|
||||
self.info.unwrap_or(SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Arc<Rgb32f>),
|
||||
Loaded(Box<DecodedSource>),
|
||||
Demo {
|
||||
scene: Box<lightcraft_scenes::Scene>,
|
||||
max_edge: usize,
|
||||
@@ -82,6 +104,9 @@ pub enum SourceRef {
|
||||
path: String,
|
||||
max_edge: usize,
|
||||
loader: Option<FileLoader>,
|
||||
/// The photo's smart preview, used when the original can't be read (offline drive). The
|
||||
/// UI thread never checks whether the original is there; the render worker finds out.
|
||||
fallback: Option<std::path::PathBuf>,
|
||||
},
|
||||
/// A smart preview standing in for a missing original.
|
||||
Smart {
|
||||
@@ -91,28 +116,41 @@ pub enum SourceRef {
|
||||
|
||||
impl SourceRef {
|
||||
pub fn load(&self) -> Result<Arc<Rgb32f>, String> {
|
||||
match self {
|
||||
SourceRef::Loaded(a) => Ok(a.clone()),
|
||||
SourceRef::Demo { scene, max_edge } => Ok(Arc::new(scene.render_fit(*max_edge))),
|
||||
SourceRef::File { path, max_edge, loader } => match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
},
|
||||
self.load_source().map(|s| s.image)
|
||||
}
|
||||
|
||||
pub fn load_source(&self) -> Result<DecodedSource, String> {
|
||||
let image = match self {
|
||||
SourceRef::Loaded(a) => return Ok((**a).clone()),
|
||||
SourceRef::Demo { scene, max_edge } => Arc::new(scene.render_fit(*max_edge)),
|
||||
SourceRef::File { path, max_edge, loader, fallback } => {
|
||||
let r = match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
};
|
||||
// An offline original renders from its smart preview (no decoder facts: header ones).
|
||||
return match (r, fallback) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
(Err(e), Some(sp)) if crate::smart::is_valid(sp) => crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")),
|
||||
(r, _) => r,
|
||||
};
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SourceRef::Smart { path } => crate::smart::load(path),
|
||||
SourceRef::Smart { path } => return crate::smart::load(path),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
|
||||
}
|
||||
SourceRef::Smart { .. } => return Err("smart previews are not available here".into()),
|
||||
};
|
||||
Ok(DecodedSource::new(image, None))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MediaCache {
|
||||
/// Decoded thumbnail-level sources (LRU by bytes).
|
||||
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
|
||||
thumbs: Lru<PhotoId, DecodedSource>,
|
||||
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
|
||||
previews: Vec<(PhotoId, Arc<Rgb32f>, u64)>,
|
||||
previews: Vec<(PhotoId, DecodedSource, u64)>,
|
||||
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
|
||||
full: Option<(PhotoId, Arc<Rgb32f>, u64)>,
|
||||
full: Option<(PhotoId, DecodedSource, u64)>,
|
||||
/// How many previews to keep (LRU).
|
||||
pub preview_capacity: usize,
|
||||
/// Bytes all decoded sources and rendered previews in memory may take together (the cache
|
||||
@@ -125,6 +163,9 @@ pub struct MediaCache {
|
||||
pub file_bytes: Option<crate::merge::ByteReader>,
|
||||
/// The library's smart previews folder (originals offline: render from the proxy).
|
||||
pub smart_dir: Option<std::path::PathBuf>,
|
||||
/// Whether originals (and smart previews) are on disk: cached and checked off the UI thread
|
||||
/// in the app (see [`crate::availability`]).
|
||||
pub availability: crate::availability::Availability,
|
||||
scenes: Vec<lightcraft_scenes::Scene>,
|
||||
/// Rendered thumbnails (memory, plus disk once a library is attached).
|
||||
pub rendered: Arc<PreviewCache>,
|
||||
@@ -144,6 +185,7 @@ impl Default for MediaCache {
|
||||
preview_loader: None,
|
||||
file_bytes: None,
|
||||
smart_dir: None,
|
||||
availability: Default::default(),
|
||||
scenes: Vec::new(),
|
||||
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
|
||||
}
|
||||
@@ -156,7 +198,7 @@ fn rendered_budget(share: usize) -> usize {
|
||||
}
|
||||
|
||||
fn source_bytes(img: &Rgb32f) -> usize {
|
||||
img.data.len() * 12 + 64
|
||||
img.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>()
|
||||
}
|
||||
|
||||
impl MediaCache {
|
||||
@@ -207,8 +249,8 @@ impl MediaCache {
|
||||
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
|
||||
let freed = match which {
|
||||
0 => self.thumbs.pop_oldest(),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)),
|
||||
2 => self.full.take().map(|e| source_bytes(&e.1)),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1.image)),
|
||||
2 => self.full.take().map(|e| source_bytes(&e.1.image)),
|
||||
_ => self.rendered.evict_oldest(),
|
||||
};
|
||||
match freed {
|
||||
@@ -219,6 +261,10 @@ impl MediaCache {
|
||||
}
|
||||
|
||||
pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
|
||||
self.get_source(id, level).map(|s| s.image)
|
||||
}
|
||||
|
||||
fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option<DecodedSource> {
|
||||
match level {
|
||||
SourceLevel::Thumb => self.thumbs.get(&id).cloned(),
|
||||
SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| {
|
||||
@@ -232,11 +278,31 @@ impl MediaCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header`
|
||||
/// (with a smart preview's stored camera tone curve).
|
||||
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
|
||||
let cached = || {
|
||||
self.thumbs
|
||||
.peek(&id)
|
||||
.into_iter()
|
||||
.chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1))
|
||||
.chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1))
|
||||
};
|
||||
match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) {
|
||||
Some(s) => s.info_or(header),
|
||||
None => header,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
|
||||
self.insert_source(id, level, DecodedSource::new(img, None));
|
||||
}
|
||||
|
||||
fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) {
|
||||
let tick = lightcraft_preview::next_tick();
|
||||
match level {
|
||||
SourceLevel::Thumb => {
|
||||
let cost = source_bytes(&img);
|
||||
let cost = source_bytes(&img.image);
|
||||
self.thumbs.insert(id, img, cost);
|
||||
}
|
||||
SourceLevel::Preview => {
|
||||
@@ -270,29 +336,36 @@ impl MediaCache {
|
||||
/// Decoded sources held: (thumbnail level, preview level, full size).
|
||||
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
|
||||
use crate::memory::Usage;
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default();
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1.image)).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1.image))).unwrap_or_default();
|
||||
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
|
||||
}
|
||||
|
||||
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
|
||||
if let Some(a) = self.get(p.id, level) {
|
||||
return SourceRef::Loaded(a);
|
||||
if let Some(a) = self.get_source(p.id, level) {
|
||||
return SourceRef::Loaded(Box::new(a));
|
||||
}
|
||||
// Procedural scenes have a nominal size: "full" is that size, not unbounded.
|
||||
let max_edge = level.max_edge().min(match (&p.source, level) {
|
||||
(Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize,
|
||||
_ => usize::MAX,
|
||||
});
|
||||
// the original is offline: its smart preview, when there is one
|
||||
// the original is offline: its smart preview, when there is one. Only cached answers are
|
||||
// used here (no file-system call on the UI thread in the app); an original not known to
|
||||
// be offline is tried first and its smart preview is the render worker's fallback.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
|
||||
&& !std::path::Path::new(path).exists()
|
||||
{
|
||||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) {
|
||||
let sp = dir.join(crate::smart::file_name(p));
|
||||
if crate::smart::is_valid(&sp) {
|
||||
// (a proxy cut short fails to load, like the offline original; the worker's fallback
|
||||
// in `SourceRef::load` only uses a proxy that passes `smart::is_valid`)
|
||||
if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) {
|
||||
return SourceRef::Smart { path: sp };
|
||||
}
|
||||
let mut r = self.origin_ref(&p.source, max_edge);
|
||||
if let SourceRef::File { fallback, .. } = &mut r {
|
||||
*fallback = Some(sp);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
self.origin_ref(&p.source, max_edge)
|
||||
}
|
||||
@@ -307,10 +380,10 @@ impl MediaCache {
|
||||
}
|
||||
match self.scenes.iter().find(|s| s.id == *scene) {
|
||||
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge },
|
||||
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None },
|
||||
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None },
|
||||
}
|
||||
}
|
||||
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone() },
|
||||
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,7 +417,7 @@ pub struct RenderResult {
|
||||
pub key: u64,
|
||||
pub rendered: Result<Rendered, String>,
|
||||
/// A source that was loaded by this job (to be inserted into the cache).
|
||||
pub loaded: Option<Arc<Rgb32f>>,
|
||||
pub loaded: Option<DecodedSource>,
|
||||
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
|
||||
pub quick: Option<QuickSource>,
|
||||
}
|
||||
@@ -410,12 +483,14 @@ impl RenderJob {
|
||||
};
|
||||
}
|
||||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||||
match self.source.load() {
|
||||
Ok(src) => {
|
||||
match self.source.load_source() {
|
||||
Ok(source) => {
|
||||
let src = &source.image;
|
||||
let info = source.info_or(self.info);
|
||||
// Thumbnails (many small jobs side by side) stay on the CPU; views and exports use
|
||||
// the GPU when there is one.
|
||||
let gpu = self.cache.is_none();
|
||||
let rendered = develop(&src, &self.info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||||
if let Some((cache, key)) = &self.cache {
|
||||
cache.put(*key, Arc::new(rendered.image.clone()));
|
||||
}
|
||||
@@ -429,7 +504,7 @@ impl RenderJob {
|
||||
level: self.level,
|
||||
key: self.key,
|
||||
rendered: Ok(rendered),
|
||||
loaded: (!was_loaded).then_some(src),
|
||||
loaded: (!was_loaded).then_some(source),
|
||||
quick: None,
|
||||
}
|
||||
}
|
||||
@@ -521,11 +596,16 @@ pub fn content_key(p: &Photo) -> String {
|
||||
pub fn source_info(p: &Photo) -> SourceInfo {
|
||||
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
|
||||
if matches!(p.source, Source::Demo { .. }) {
|
||||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
|
||||
return SourceInfo { raw: true, ..Default::default() };
|
||||
}
|
||||
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
|
||||
// balance and the display tone curve, like any other rendered file.
|
||||
if p.develops_raw() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() }
|
||||
if p.develops_raw() {
|
||||
let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) };
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() }
|
||||
} else {
|
||||
SourceInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
@@ -680,7 +760,7 @@ impl crate::Session {
|
||||
/// Accept a finished job's loaded source into the cache.
|
||||
pub fn accept(&mut self, r: &RenderResult) {
|
||||
if let Some(src) = &r.loaded {
|
||||
self.media.insert(r.photo, r.level, src.clone());
|
||||
self.media.insert_source(r.photo, r.level, src.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,12 +803,19 @@ impl crate::Session {
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Prefer decoder facts to header-only metadata for pixel-statistics commands.
|
||||
pub fn source_info(&self, id: PhotoId) -> SourceInfo {
|
||||
let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default();
|
||||
self.media.source_facts(id, header)
|
||||
}
|
||||
|
||||
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
|
||||
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
let r = self.media.source_ref(&p, level).load()?;
|
||||
self.media.insert(id, level, r.clone());
|
||||
Ok(r)
|
||||
let r = self.media.source_ref(&p, level).load_source()?;
|
||||
let image = r.image.clone();
|
||||
self.media.insert_source(id, level, r);
|
||||
Ok(image)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,6 +847,42 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decoder_info_survives_render_jobs_cache_and_eviction() {
|
||||
let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.01 * (i + 1) as f32;
|
||||
[x, (x * 2.0).min(0.9)]
|
||||
}))
|
||||
.unwrap();
|
||||
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() };
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
|
||||
job.source = SourceRef::File {
|
||||
path: "synthetic.arw".into(),
|
||||
max_edge: 64,
|
||||
loader: Some(Arc::new(move |_, _| {
|
||||
let mut image = Rgb32f::new(64, 64);
|
||||
image.data.fill([0.1; 3]);
|
||||
Ok((image, info))
|
||||
})),
|
||||
fallback: None,
|
||||
};
|
||||
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
|
||||
job.settings = Arc::new(DevelopSettings::default());
|
||||
let r = job.clone().run();
|
||||
assert_eq!(r.loaded.as_ref().unwrap().info, Some(info));
|
||||
let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request);
|
||||
assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data);
|
||||
s.accept(&r);
|
||||
job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level);
|
||||
let again = job.run();
|
||||
assert!(again.loaded.is_none());
|
||||
assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data);
|
||||
s.media.forget(id);
|
||||
assert!(s.media.get_source(id, r.level).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_evicts_least_recently_used_across_caches() {
|
||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||
|
||||
+87
-64
@@ -538,6 +538,92 @@ fn set_file_ops_to(op: &Op, to: &HashSet<&str>, out: &mut Vec<Op>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies)
|
||||
/// rename once. No file-system calls.
|
||||
pub fn rename_photos(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> Vec<std::sync::Arc<Photo>> {
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
ids.iter()
|
||||
.filter_map(|id| cat.photo(*id))
|
||||
.filter(|p| match &p.source {
|
||||
Source::File { path } => seen_paths.insert(path.clone()),
|
||||
_ => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`Session::plan_rename`] for [`rename_photos`], with the "is this name taken on disk?" check
|
||||
/// supplied: the Rename dialog plans its preview on a worker thread (a check can block on a slow
|
||||
/// drive). Whether a case-only change names this very file is decided on the disk.
|
||||
pub fn plan_rename_photos(photos: &[std::sync::Arc<Photo>], template: &str, start: usize, exists: &dyn Fn(&str) -> bool) -> Vec<RenamePlan> {
|
||||
plan_rename_core(photos, template, start, &|p| exists(&p.to_string_lossy()), &|a, b| RealFs.same_file(a, b))
|
||||
}
|
||||
|
||||
/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether
|
||||
/// two spellings name one file (see [`MoveFs`]).
|
||||
fn plan_rename_core(
|
||||
photos: &[std::sync::Arc<Photo>],
|
||||
template: &str,
|
||||
start: usize,
|
||||
exists: &dyn Fn(&Path) -> bool,
|
||||
same_file: &dyn Fn(&Path, &Path) -> bool,
|
||||
) -> Vec<RenamePlan> {
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
for (seq, p) in (start..).zip(photos) {
|
||||
let want = expand(template, p, seq);
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
let candidate = |k: usize| {
|
||||
if k == 0 {
|
||||
want.clone()
|
||||
} else if ext.is_empty() {
|
||||
format!("{stem}-{k}")
|
||||
} else {
|
||||
format!("{stem}-{k}.{ext}")
|
||||
}
|
||||
};
|
||||
let (to, to_path) = match &p.source {
|
||||
Source::File { path } => {
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = tp.to_lowercase();
|
||||
// this very file, maybe spelled differently (case-insensitive volume)? By
|
||||
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
|
||||
let (from, to) = (Path::new(path), Path::new(&tp));
|
||||
let same = tp == *path || (from != to && key == path.to_lowercase() && exists(to) && same_file(from, to));
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
if !taken.contains(&key) && (same || !exists(to)) {
|
||||
taken.insert(key);
|
||||
break (name, Some(tp));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Source::Demo { .. } => {
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
if taken.insert(format!("demo:{}", name.to_lowercase())) {
|
||||
break (name, None);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
let from_path = match &p.source {
|
||||
Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
out.push(RenamePlan { id: p.id.0, from: p.file_name.clone(), to, from_path, to_path });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Plan renaming `ids` with `template` (sequence numbers from `start`), resolving collisions.
|
||||
pub fn plan_rename(&self, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
@@ -546,70 +632,7 @@ impl Session {
|
||||
|
||||
/// [`Session::plan_rename`] on the file system `fs`.
|
||||
pub(crate) fn plan_rename_with(&self, fs: &dyn MoveFs, ids: &[PhotoId], template: &str, start: usize) -> Vec<RenamePlan> {
|
||||
// photos sharing one file (virtual copies) rename once
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
let mut seq = start;
|
||||
for id in ids {
|
||||
let Some(p) = self.catalog.photo(*id) else { continue };
|
||||
if let Source::File { path } = &p.source
|
||||
&& !seen_paths.insert(path.clone())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let want = expand(template, p, seq);
|
||||
seq += 1;
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
let candidate = |k: usize| {
|
||||
if k == 0 {
|
||||
want.clone()
|
||||
} else if ext.is_empty() {
|
||||
format!("{stem}-{k}")
|
||||
} else {
|
||||
format!("{stem}-{k}.{ext}")
|
||||
}
|
||||
};
|
||||
let (to, to_path) = match &p.source {
|
||||
Source::File { path } => {
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
let key = tp.to_lowercase();
|
||||
// this very file, maybe spelled differently (case-insensitive volume)? By
|
||||
// identity: on a case-sensitive volume `img_1.jpg` may be another photo
|
||||
let same = tp == *path || is_respelling(fs, Path::new(path), Path::new(&tp));
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
let on_disk = fs.exists(Path::new(&tp)) && !same;
|
||||
if !taken.contains(&key) && !on_disk {
|
||||
taken.insert(key);
|
||||
break (name, Some(tp));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Source::Demo { .. } => {
|
||||
let mut k = 0;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
if taken.insert(format!("demo:{}", name.to_lowercase())) {
|
||||
break (name, None);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
let from_path = match &p.source {
|
||||
Source::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
};
|
||||
out.push(RenamePlan { id: id.0, from: p.file_name.clone(), to, from_path, to_path });
|
||||
}
|
||||
out
|
||||
plan_rename_core(&rename_photos(&self.catalog, ids), template, start, &|p| fs.exists(p), &|a, b| fs.same_file(a, b))
|
||||
}
|
||||
|
||||
/// Carry out `plans`: move the files (rolled back on failure), then commit one undoable op
|
||||
|
||||
+60
-13
@@ -1,13 +1,16 @@
|
||||
//! Smart previews: a compact proxy of each photo (its decoded preview-size source, about 1 MB)
|
||||
//! kept in the library, so photos stay editable — and exportable at proxy size — while their
|
||||
//! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into
|
||||
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header.
|
||||
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header
|
||||
//! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied
|
||||
//! at render time and is not baked into the pixels.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::Photo;
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
use lightcraft_pipeline::tone::CameraTone;
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
|
||||
const MAGIC: &[u8] = b"LCSP1\n";
|
||||
@@ -18,8 +21,11 @@ pub fn file_name(p: &Photo) -> String {
|
||||
format!("{:032x}.lcsp", h.0)
|
||||
}
|
||||
|
||||
/// Encode a source image as a smart preview.
|
||||
pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
|
||||
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
|
||||
const HEADER_MAX: u64 = 4096;
|
||||
|
||||
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
|
||||
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
|
||||
// scale so all but the brightest 0.05 % fit into 0..1
|
||||
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
|
||||
let scale = if lum.is_empty() {
|
||||
@@ -47,22 +53,29 @@ pub fn encode(img: &Rgb32f) -> Result<Vec<u8>, String> {
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = MAGIC.to_vec();
|
||||
out.extend_from_slice(format!("{{\"w\":{},\"h\":{},\"scale\":{scale}}}\n", img.width, img.height).as_bytes());
|
||||
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale});
|
||||
if let Some(t) = tone {
|
||||
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
|
||||
}
|
||||
out.extend_from_slice(head.to_string().as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(&jpg);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode a smart preview back into a source image.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Rgb32f, String> {
|
||||
/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid
|
||||
/// curve is ignored, like a missing one).
|
||||
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>), String> {
|
||||
let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?;
|
||||
let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?;
|
||||
let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?;
|
||||
let scale = head["scale"].as_f64().unwrap_or(1.0) as f32;
|
||||
let tone = head.get("tone").and_then(|t| serde_json::from_value::<CameraTone>(t.clone()).ok());
|
||||
let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?;
|
||||
// the decoder undoes the sRGB encoding; the values are the source's own primaries
|
||||
let mut img = d.image;
|
||||
img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale));
|
||||
Ok(img)
|
||||
Ok((img, tone))
|
||||
}
|
||||
|
||||
/// Where a library keeps its smart previews.
|
||||
@@ -157,8 +170,8 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec<String>) {
|
||||
pub fn is_valid(path: &Path) -> bool {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let Ok(mut f) = std::fs::File::open(path) else { return false };
|
||||
let mut head = Vec::with_capacity(256);
|
||||
if (&mut f).take(256).read_to_end(&mut head).is_err() {
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
if (&mut f).take(HEADER_MAX).read_to_end(&mut head).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
|
||||
@@ -175,9 +188,9 @@ pub fn is_valid(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Load the proxy at `path`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, String> {
|
||||
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
decode(&b).map(Arc::new)
|
||||
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -228,7 +241,7 @@ mod tests {
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let good = encode(&img).unwrap();
|
||||
let good = encode(&img, None).unwrap();
|
||||
std::fs::write(a.join("1.lcsp"), &good).unwrap();
|
||||
assert!(is_valid(&a.join("1.lcsp")));
|
||||
for cut in [good.len() - 1, good.len() / 2, 10, 0] {
|
||||
@@ -242,11 +255,45 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_tone_travels_with_the_proxy() {
|
||||
let tone = CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.004 * 1.17f32.powi(i as i32);
|
||||
[x, 0.95 * (1.0 - (-2.7 * x).exp())]
|
||||
}))
|
||||
.unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let bytes = encode(&img, Some(&tone)).unwrap();
|
||||
assert_eq!(decode(&bytes).unwrap().1, Some(tone));
|
||||
let dir = temp("tone");
|
||||
let path = dir.join("t.lcsp");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
// the longer header is still read by the validity check, and the curve by `load`
|
||||
assert!(is_valid(&path));
|
||||
let loaded = load(&path).unwrap();
|
||||
assert_eq!(loaded.camera_tone, Some(tone));
|
||||
let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() };
|
||||
assert_eq!(loaded.info_or(header).camera_tone, Some(tone));
|
||||
// a hostile curve (reversing knots) is ignored, not trusted
|
||||
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
|
||||
let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
|
||||
let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap();
|
||||
knots.swap(3, 4);
|
||||
head["tone"]["knots"] = serde_json::json!(knots);
|
||||
let mut hostile = MAGIC.to_vec();
|
||||
hostile.extend_from_slice(head.to_string().as_bytes());
|
||||
hostile.extend_from_slice(&bytes[nl..]);
|
||||
let (pixels, tone) = decode(&hostile).unwrap();
|
||||
assert_eq!((pixels.width, tone), (img.width, None));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_keeps_the_picture() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
|
||||
let back = decode(&encode(&img).unwrap()).unwrap();
|
||||
let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap();
|
||||
assert_eq!((back.width, back.height), (img.width, img.height));
|
||||
assert!(tone.is_none());
|
||||
let mut err = 0.0f64;
|
||||
for (a, b) in img.data.iter().zip(&back.data) {
|
||||
for k in 0..3 {
|
||||
|
||||
@@ -10,6 +10,30 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
|
||||
(*s.develop_of(s.active().unwrap()).unwrap()).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
let mut s = demo();
|
||||
let id = PhotoId(100);
|
||||
let mut p = Photo::new(id, Source::File { path: "synthetic.arw".into() }, "synthetic.arw", "ARW", 16, 16, "");
|
||||
// A catalog made by the old generic-matrix Kelvin inference.
|
||||
p.as_shot_wb = Some((6829.0, -127.0));
|
||||
p.develop = std::sync::Arc::new(DevelopSettings::for_raw(6829.0, -127.0));
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [100], "active": 100})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "wb.temp", "value": 8000})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(d.wb.mode, WbMode::Custom);
|
||||
assert_eq!(d.wb.temp, 8000.0);
|
||||
assert_eq!(d.wb.tint, 0.0, "untouched tint comes from the current as-shot reference");
|
||||
s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "wb.tint", "value": 10})).unwrap();
|
||||
assert_eq!(active_dev(&s).wb.temp, 6500.0);
|
||||
s.execute("develop.reset", &json!({})).unwrap();
|
||||
assert_eq!((active_dev(&s).wb.temp, active_dev(&s).wb.tint), (6500.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_library_loads() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -144,11 +144,10 @@ fn browsing_a_folder_lists_its_photos_without_adding_them() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
|
||||
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
|
||||
#[test]
|
||||
fn find_missing_matches_renamed_files_by_content() {
|
||||
let dir = temp_dir("missing-content");
|
||||
/// Photos a.png, b.png, c.png imported from `old`, then: a renamed into `moved`; b renamed, with
|
||||
/// an impostor (same name and size, other bytes) under its old name; c gone, with two impostors.
|
||||
fn renamed_and_ambiguous_scene(tag: &str) -> (std::path::PathBuf, Session) {
|
||||
let dir = temp_dir(tag);
|
||||
std::fs::create_dir_all(dir.join("old")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("moved/real")).unwrap();
|
||||
for (n, seed) in [("a.png", 1), ("b.png", 2), ("c.png", 3)] {
|
||||
@@ -156,20 +155,26 @@ fn find_missing_matches_renamed_files_by_content() {
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
|
||||
// an impostor: same name and size, other bytes
|
||||
let impostor = |src: &Path, dst: &Path| {
|
||||
let mut b = std::fs::read(src).unwrap();
|
||||
let k = b.len() - 20;
|
||||
b[k] ^= 0xff;
|
||||
std::fs::write(dst, b).unwrap();
|
||||
};
|
||||
// a: renamed; b: renamed, with an impostor under its old name; c: gone, two impostors
|
||||
std::fs::rename(dir.join("old/a.png"), dir.join("moved/Trip-001.png")).unwrap();
|
||||
impostor(&dir.join("old/b.png"), &dir.join("moved/b.png"));
|
||||
std::fs::rename(dir.join("old/b.png"), dir.join("moved/real/Trip-002.png")).unwrap();
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/c.png"));
|
||||
impostor(&dir.join("old/c.png"), &dir.join("moved/real/c.png"));
|
||||
std::fs::remove_file(dir.join("old/c.png")).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// Issue #105: Find Missing finds renamed files by content, prefers the content match over a
|
||||
/// same-name same-size impostor, and skips (reports) photos it can't tell apart.
|
||||
#[test]
|
||||
fn find_missing_matches_renamed_files_by_content() {
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-content");
|
||||
let r = s.execute("library.findMissing", &json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
|
||||
let found = r["found"].as_array().unwrap();
|
||||
assert_eq!(found.len(), 2, "{r}");
|
||||
@@ -183,6 +188,55 @@ fn find_missing_matches_renamed_files_by_content() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #104: the app plans Find Missing on a worker thread (no session) and relinks the plan
|
||||
/// afterwards; that gives exactly what the synchronous command gives — renamed files by content,
|
||||
/// impostors and look-alikes skipped (`ambiguous`).
|
||||
#[test]
|
||||
fn find_missing_planned_on_a_worker_matches_the_command() {
|
||||
use crate::cmd::missing::{find_candidates, plan_find_missing};
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-worker");
|
||||
let folder = dir.join("moved").to_string_lossy().to_string();
|
||||
let candidates = find_candidates(&s.catalog);
|
||||
let f = folder.clone();
|
||||
let plan = std::thread::spawn(move || plan_find_missing(&candidates, &f)).join().unwrap().unwrap();
|
||||
assert_eq!((plan.found.len(), plan.missing, plan.ambiguous.len()), (2, 1, 1), "{plan:?}");
|
||||
let applied = s.execute("library.findMissing", &plan.to_json()).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(3), "undo restores all three");
|
||||
let direct = s.execute("library.findMissing", &json!({"folder": folder})).unwrap();
|
||||
assert_eq!(applied, direct);
|
||||
assert_eq!(direct["found"].as_array().map(Vec::len), Some(2), "{direct}");
|
||||
assert_eq!(direct["ambiguous"].as_array().map(Vec::len), Some(1), "{direct}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #104: a plan made on a worker is re-checked when it is applied: a photo relinked
|
||||
/// meanwhile is left alone, and a file that is now another photo's original isn't used.
|
||||
#[test]
|
||||
fn find_missing_plan_is_rechecked_when_applied() {
|
||||
use crate::cmd::missing::{find_candidates, plan_find_missing};
|
||||
let (dir, mut s) = renamed_and_ambiguous_scene("missing-recheck");
|
||||
let plan = plan_find_missing(&find_candidates(&s.catalog), &dir.join("moved").to_string_lossy()).unwrap();
|
||||
let to = |name: &str| plan.found.iter().find(|f| f.from.ends_with(name)).unwrap().clone();
|
||||
let (a, b) = (to("a.png"), to("b.png"));
|
||||
// meanwhile: a is relinked by hand (elsewhere), and the ambiguous c is pointed at b's file
|
||||
std::fs::copy(&a.to, dir.join("a-elsewhere.png")).unwrap();
|
||||
s.execute("photo.relink", &json!({"id": a.id.0, "path": dir.join("a-elsewhere.png").to_string_lossy()})).unwrap();
|
||||
let c = plan.ambiguous[0].id;
|
||||
s.execute("photo.relink", &json!({"id": c.0, "path": b.to})).unwrap();
|
||||
let r = s.execute("library.findMissing", &plan.to_json()).unwrap();
|
||||
assert_eq!(r["found"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert_eq!(r["ambiguous"].as_array().map(Vec::len), Some(0), "c isn't missing any more: {r}");
|
||||
assert_eq!(r["missing"], 1, "b stays missing: its file is c's now: {r}");
|
||||
let path_of = |id: lightcraft_catalog::PhotoId| match &s.catalog.photo(id).unwrap().source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!(path_of(a.id), dir.join("a-elsewhere.png").to_string_lossy());
|
||||
assert_eq!(path_of(b.id), b.from);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_files_are_found_and_relinked() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id()));
|
||||
@@ -425,6 +479,20 @@ fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() {
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole");
|
||||
// the app's background build (issue #104) checks and repairs the same way, on a worker thread
|
||||
std::fs::write(&file, &full[..full.len() / 2]).unwrap();
|
||||
let r = s.execute("library.smartPreviews", &json!({"background": true})).unwrap();
|
||||
assert_eq!(r["what"], "smart previews", "{r}");
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = loop {
|
||||
let r = s.execute("library.previewProgress", &json!({})).unwrap();
|
||||
if r["running"] == false || t0.elapsed() > std::time::Duration::from_secs(60) {
|
||||
break r;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!((r["done"].as_u64(), r["failed"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0), Some(1)), "{r}");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole in the background");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -518,6 +586,70 @@ fn folders_rename_and_move_with_their_photos() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Rename / Move Folder are undo steps (issue #97): undo renames the folder back on disk (files and
|
||||
/// sidecars along) and restores the photos' paths, redo repeats it; a destination taken in the
|
||||
/// meantime is never overwritten — the step is refused, reported and stays on the stack.
|
||||
#[test]
|
||||
fn folder_rename_and_move_undo_and_redo_on_disk() {
|
||||
let root = temp_dir("folders-undo");
|
||||
let a = root.join("Trip");
|
||||
write_png(&a.join("one.png"), 3);
|
||||
std::fs::write(a.join("one.xmp"), "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>").unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let path = |s: &Session| match &s.catalog.photo(id).unwrap().source {
|
||||
lightcraft_catalog::Source::File { path } => path.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
let b = root.join("Italy");
|
||||
s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy"})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo0 + 1, "one undo step");
|
||||
assert!(s.undo.last().unwrap().label.contains("Rename Folder"));
|
||||
assert!(b.join("one.png").exists() && !a.exists());
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(a.join("one.png").exists() && a.join("one.xmp").exists() && !b.exists(), "renamed back on disk");
|
||||
assert_eq!(path(&s), a.join("one.png").to_string_lossy());
|
||||
s.media.forget(id);
|
||||
assert!(s.render_now(id, 16, 16).is_ok());
|
||||
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
|
||||
// the old name is taken now: undo is refused, nothing is overwritten, the step stays
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::write(a.join("keep.txt"), "mine").unwrap();
|
||||
let undo_n = s.undo.len();
|
||||
let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("already exists"), "{e}");
|
||||
assert_eq!(s.undo.len(), undo_n, "the step stays on the stack");
|
||||
assert_eq!(std::fs::read_to_string(a.join("keep.txt")).unwrap(), "mine");
|
||||
assert!(b.join("one.png").exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
std::fs::remove_dir_all(&a).unwrap();
|
||||
|
||||
// move into another folder, undo, redo
|
||||
let into = root.join("Archive");
|
||||
s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": into.to_string_lossy()})).unwrap();
|
||||
let moved = into.join("Italy");
|
||||
assert_eq!(path(&s), moved.join("one.png").to_string_lossy());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(b.join("one.png").exists() && !moved.exists());
|
||||
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
||||
// the redo target is taken (even by an empty folder): refused, nothing replaced
|
||||
std::fs::create_dir_all(&moved).unwrap();
|
||||
assert!(s.execute("edit.redo", &json!({})).is_err());
|
||||
assert!(b.join("one.png").exists());
|
||||
std::fs::remove_dir(&moved).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(moved.join("one.png").exists() && moved.join("one.xmp").exists() && !b.exists());
|
||||
assert_eq!(path(&s), moved.join("one.png").to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an
|
||||
/// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected.
|
||||
#[test]
|
||||
@@ -667,6 +799,7 @@ fn smart_previews_folder_is_chosen_per_library() {
|
||||
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true);
|
||||
|
||||
// the setting is saved with the library
|
||||
drop(s); // one session per library (issue #99)
|
||||
let mut again = Session::new().with_fs();
|
||||
again.open_library(&lib, false).unwrap();
|
||||
assert_eq!(again.execute("library.smartPreviewsLocation", &json!({})).unwrap()["path"], to.to_string_lossy().as_ref());
|
||||
|
||||
@@ -113,6 +113,7 @@ fn close_writes_snapshot_and_view_state() {
|
||||
let expect = s.catalog.to_snapshot();
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(std::fs::metadata(dir.join("catalog.log")).unwrap().len(), 0);
|
||||
drop(s); // one session per library (issue #99)
|
||||
|
||||
let s2 = open(&dir, true);
|
||||
let r = &s2.library.as_ref().unwrap().report;
|
||||
@@ -178,6 +179,7 @@ fn new_library_without_seed_is_empty_and_compacts() {
|
||||
assert!(p["snapshots"].as_u64() >= Some(1), "{p}");
|
||||
assert!(p["lastSnapshot"]["bytes"].as_u64() > Some(0), "{p}");
|
||||
assert!(p["lastSnapshot"]["totalMs"].as_f64() >= p["lastSnapshot"]["serializeMs"].as_f64(), "{p}");
|
||||
drop(s); // one session per library (issue #99)
|
||||
let s2 = open(&dir, true);
|
||||
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
|
||||
assert_eq!(s2.catalog.albums().count(), 1);
|
||||
@@ -376,3 +378,32 @@ fn scale_100k_library_queries() {
|
||||
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
eprintln!("slowest: {:?}", &worst[..3]);
|
||||
}
|
||||
|
||||
/// Issue #99: a library is open in one session at a time. A second opener is refused (nothing
|
||||
/// read or written), the owning session can reopen it, and it is free again once closed.
|
||||
#[test]
|
||||
fn a_library_open_elsewhere_is_refused() {
|
||||
let dir = temp_dir("locked");
|
||||
let mut s = open(&dir, true);
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
let log = std::fs::read(dir.join("catalog.log")).unwrap();
|
||||
|
||||
let mut other = Session::new();
|
||||
let e = other.open_library(&dir, true).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::LibraryInUse(_)), "{e:?}");
|
||||
assert!(e.to_string().contains("already open in"), "{e}");
|
||||
assert!(other.library.is_none());
|
||||
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), log, "the refused opener wrote nothing");
|
||||
|
||||
// the session that has it open may reopen it (Settings → Open Library on the same folder)
|
||||
s.close_library().unwrap();
|
||||
s.open_library(&dir, true).unwrap();
|
||||
assert!(other.open_library(&dir, true).is_err(), "still locked after reopening");
|
||||
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
|
||||
let expect = s.catalog.to_snapshot();
|
||||
|
||||
drop(s);
|
||||
other.open_library(&dir, true).unwrap();
|
||||
assert_eq!(other.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -126,11 +126,13 @@ fn preferences_persist_with_the_library() {
|
||||
assert_eq!(r["persistent"], true);
|
||||
// export presets are library preferences too
|
||||
s.execute("export.savePreset", &json!({"name": "Proof", "params": {"format": "jpeg", "percent": 25}})).unwrap();
|
||||
drop(s); // one session per library (issue #99)
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.export_params(&json!({"preset": "Proof"})).unwrap(), json!({"format": "jpeg", "percent": 25}));
|
||||
// older prefs.json files (snake_case `last_export`, no import section) still load
|
||||
std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap();
|
||||
drop(s);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.last_export, Some(json!({"format": "png"})));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Issue #103: a damaged or unreadable settings file (prefs.json, presets.json, view.json) is
|
||||
//! never silently replaced by the defaults: a damaged one is kept aside, an unreadable one is not
|
||||
//! written this session, and the user is told.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_catalog::{MemStore, Store};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::library::LibraryStores;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-settings-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_prefs_are_kept_aside_and_reported() {
|
||||
let lib = temp_dir("damaged");
|
||||
let bad = br#"{"exportPresets": [{"name": "Proof", "par"#;
|
||||
std::fs::write(lib.join("prefs.json"), bad).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let kept: Vec<_> = std::fs::read_dir(&lib)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.starts_with("prefs.json.corrupt-"))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "{kept:?}");
|
||||
assert_eq!(std::fs::read(lib.join(&kept[0])).unwrap(), bad);
|
||||
let warnings = s.take_library_warnings();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("prefs.json is damaged") && warnings[0].contains(&kept[0]), "{warnings:?}");
|
||||
assert!(s.take_library_warnings().is_empty(), "each warning is handed out once");
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
assert_eq!(info["settingsWarnings"].as_array().map(Vec::len), Some(1), "{info}");
|
||||
// saving goes on (the damaged original is safe in its copy)
|
||||
s.execute("library.preferences", &json!({"cacheMb": 300})).unwrap();
|
||||
assert!(String::from_utf8(std::fs::read(lib.join("prefs.json")).unwrap()).unwrap().contains("300"));
|
||||
assert_eq!(std::fs::read(lib.join(&kept[0])).unwrap(), bad);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// `presets.json` and `prefs.json` can't be read (locked by another program, an I/O error).
|
||||
#[derive(Clone, Default)]
|
||||
struct Unreadable {
|
||||
files: MemStore,
|
||||
unreadable: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Store for Unreadable {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
if self.unreadable.lock().unwrap().iter().any(|n| n == name) {
|
||||
return Err(std::io::Error::other("locked by another process"));
|
||||
}
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"unreadable".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_settings_are_not_overwritten() {
|
||||
let files = Unreadable::default();
|
||||
let presets = br#"{"user": [{"id": "user.mine", "name": "Mine", "group": "User Presets", "settings": {}}]}"#.to_vec();
|
||||
let prefs = br#"{"cacheMb": 777}"#.to_vec();
|
||||
files.files.set("presets.json", presets.clone());
|
||||
files.files.set("prefs.json", prefs.clone());
|
||||
*files.unreadable.lock().unwrap() = vec!["presets.json".into(), "prefs.json".into()];
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "unreadable".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
let warnings = s.take_library_warnings();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings.iter().all(|w| w.contains("couldn't be read") && w.contains("won't overwrite")), "{warnings:?}");
|
||||
|
||||
// a preset change and a preference change: neither file is replaced by defaults
|
||||
s.execute("preset.create", &json!({"name": "New"})).unwrap();
|
||||
let e = s.execute("library.preferences", &json!({"cacheMb": 300}));
|
||||
assert!(e.is_err(), "saving preferences is refused, not done over the unread file");
|
||||
assert_eq!(files.files.get("presets.json").unwrap(), presets);
|
||||
assert_eq!(files.files.get("prefs.json").unwrap(), prefs);
|
||||
}
|
||||
@@ -22,6 +22,27 @@ fn gpu() -> bool {
|
||||
ok
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_tone_and_relative_wb() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let src = scene(2, 320, 240);
|
||||
let curve = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.004 * 1.18f32.powi(i as i32);
|
||||
[x, 1.0 - (-2.0 * x).exp()]
|
||||
}))
|
||||
.unwrap();
|
||||
let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(curve), ..Default::default() };
|
||||
let mut s = DevelopSettings::default();
|
||||
check("camera tone neutral", &src, &info, &s, &RenderRequest::fit(320, 240));
|
||||
s.light.exposure = 1.0;
|
||||
s.light.contrast = 40.0;
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = 8000.0;
|
||||
check("camera tone edited", &src, &info, &s, &RenderRequest::fit(320, 240));
|
||||
}
|
||||
|
||||
fn scene(i: usize, w: usize, h: usize) -> Arc<Rgb32f> {
|
||||
Arc::new(lightcraft_scenes::demo_library()[i].render(w, h))
|
||||
}
|
||||
|
||||
@@ -221,7 +221,9 @@ impl FinishParams {
|
||||
FinishParams {
|
||||
calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None },
|
||||
shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 },
|
||||
tone: if info.raw {
|
||||
tone: if let Some(curve) = info.camera_tone.as_ref().filter(|_| info.raw) {
|
||||
ToneMap::camera(curve, s.light.contrast, s.light.whites, s.light.blacks)
|
||||
} else if info.raw {
|
||||
ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks)
|
||||
} else {
|
||||
ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks)
|
||||
|
||||
@@ -64,11 +64,14 @@ pub struct SourceInfo {
|
||||
pub raw: bool,
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
/// No measured camera illuminant: WB adjustments are relative to the camera's rendered look.
|
||||
pub relative_wb: bool,
|
||||
pub camera_tone: Option<tone::CameraTone>,
|
||||
}
|
||||
|
||||
impl Default for SourceInfo {
|
||||
fn default() -> Self {
|
||||
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None }
|
||||
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, relative_wb: false, camera_tone: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn effective_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) {
|
||||
use lightcraft_develop::WbMode;
|
||||
match s.wb.mode {
|
||||
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
|
||||
m if info.raw => m.preset().unwrap_or((s.wb.temp, s.wb.tint)),
|
||||
m if info.raw && !info.relative_wb => m.preset().unwrap_or((s.wb.temp, s.wb.tint)),
|
||||
_ => (s.wb.temp, s.wb.tint),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,75 @@ pub const LUT_MIN_EV: f32 = -14.0;
|
||||
pub const LUT_MAX_EV: f32 = 10.0;
|
||||
pub const LUT_N: usize = 4096;
|
||||
|
||||
/// A file-local camera look, fitted independently of the scene-linear colour transform.
|
||||
/// Knots are scene/display-linear luminance pairs. Keeping this in the finish stage preserves
|
||||
/// RAW exposure and highlight headroom; it is never baked into the decoded sensor pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct CameraTone {
|
||||
knots: [[f32; 2]; 32],
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for CameraTone {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Wire {
|
||||
knots: [[f32; 2]; 32],
|
||||
}
|
||||
let w = Wire::deserialize(d)?;
|
||||
Self::new(w.knots).ok_or_else(|| serde::de::Error::custom("invalid camera tone curve"))
|
||||
}
|
||||
}
|
||||
|
||||
impl CameraTone {
|
||||
pub fn new(knots: [[f32; 2]; 32]) -> Option<Self> {
|
||||
let mut previous = [0.0, 0.0];
|
||||
for p in knots {
|
||||
if !p.iter().all(|v| v.is_finite()) || p[0] <= previous[0] || p[1] < previous[1] || p[1] >= 1.0 {
|
||||
return None;
|
||||
}
|
||||
previous = p;
|
||||
}
|
||||
Some(Self { knots })
|
||||
}
|
||||
|
||||
pub fn apply(&self, y: f32) -> f32 {
|
||||
if !y.is_finite() || y <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut previous = [0.0, 0.0];
|
||||
for p in self.knots {
|
||||
if y <= p[0] {
|
||||
let t = (y - previous[0]) / (p[0] - previous[0]);
|
||||
return previous[1] + t * (p[1] - previous[1]);
|
||||
}
|
||||
previous = p;
|
||||
}
|
||||
let a = self.knots[30];
|
||||
let b = self.knots[31];
|
||||
// Extend beyond observed (unclipped) highlights with a continuous, bounded shoulder.
|
||||
let slope = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0);
|
||||
1.0 - (1.0 - b[1]) * (-(y - b[0]) * slope / (1.0 - b[1]).max(0.01)).exp()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToneMap {
|
||||
lut: Vec<f32>,
|
||||
}
|
||||
|
||||
impl ToneMap {
|
||||
pub fn camera(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64) -> ToneMap {
|
||||
let adjustment = Self::display(contrast, whites, blacks);
|
||||
let neutral = contrast == 0.0 && whites == 0.0 && blacks == 0.0;
|
||||
let lut = (0..LUT_N)
|
||||
.map(|i| {
|
||||
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
|
||||
let y = curve.apply(GREY * 2f32.powf(ev));
|
||||
if neutral { y } else { adjustment.apply(y) }
|
||||
})
|
||||
.collect();
|
||||
ToneMap { lut }
|
||||
}
|
||||
/// `contrast`, `whites`, `blacks` in −100..100 (Lightroom slider units).
|
||||
pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap {
|
||||
let c = (contrast / 100.0) as f32;
|
||||
@@ -116,6 +179,32 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn camera_curve_preserves_black_and_extends_headroom() {
|
||||
let knots = std::array::from_fn(|i| {
|
||||
let x = 0.005 * 1.15f32.powi(i as i32);
|
||||
[x, 1.0 - (-3.0 * x).exp()]
|
||||
});
|
||||
let curve = CameraTone::new(knots).unwrap();
|
||||
let base = ToneMap::camera(&curve, 0.0, 0.0, 0.0);
|
||||
assert_eq!(base.apply(0.0), 0.0);
|
||||
assert!(base.apply(0.1) < base.apply(0.2));
|
||||
let mut previous = 0.0;
|
||||
for i in 0..2000 {
|
||||
let y = 1e-6 * 1.01f32.powi(i);
|
||||
let v = base.apply(y);
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
assert!(v >= previous - 1e-6);
|
||||
previous = v;
|
||||
}
|
||||
assert!((base.apply(0.1) - curve.apply(0.1)).abs() < 0.001);
|
||||
assert!(ToneMap::camera(&curve, 50.0, 0.0, 0.0).apply(0.3) > base.apply(0.3));
|
||||
let mut invalid = knots;
|
||||
invalid[1][0] = invalid[0][0];
|
||||
assert!(CameraTone::new(invalid).is_none());
|
||||
assert!(serde_json::from_value::<CameraTone>(serde_json::json!({"knots": invalid})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_and_bounded() {
|
||||
for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] {
|
||||
|
||||
@@ -25,3 +25,6 @@ log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
lightcraft-codecs = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
" · {skipped} skipped (file exists)": "・{skipped}件は同名ファイルのためスキップ",
|
||||
" · {} duplicate{} skipped": "・重複{}枚をスキップ{:.0}",
|
||||
" · {} failed: {}": "・{}件失敗:{}",
|
||||
" · {} moved": "・{}件移動",
|
||||
" · {} not readable": "・{}件は読み込み不可",
|
||||
", {failed} file{} not readable": "、{failed}件を読み込めません{:.0}",
|
||||
"Add {local_n} to My Photos": "{local_n}枚をマイフォトに追加",
|
||||
"Add {} Photo{}": "{}枚の写真を追加{:.0}",
|
||||
"Add “{}” to Favorites": "「{}」をお気に入りに追加",
|
||||
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
|
||||
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
|
||||
"All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします",
|
||||
"Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用",
|
||||
"Automatic ({} MB)": "自動({}MB)",
|
||||
"Click {}:1": "クリック時 {}:1",
|
||||
"Create {n} Virtual Copies": "{n}枚の仮想コピーを作成",
|
||||
"Decoder: {reason}": "デコーダー:{reason}",
|
||||
"Delete \"{label}\"": "「{label}」を削除",
|
||||
"Delete \"{label}\" (and the mask)": "「{label}」とマスクを削除",
|
||||
"Delete {n} Photos": "{n}枚の写真を削除",
|
||||
"Delete “{current}”": "「{current}」を削除",
|
||||
"Duplicate \"{label}\"": "「{label}」を複製",
|
||||
"Example: {example}": "例:{example}",
|
||||
"Exported {n} photo{}": "{n}枚を書き出しました{:.0}",
|
||||
"Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.0}",
|
||||
"Exported {} point curve preset{}": "{}件のポイントカーブを書き出しました{:.0}",
|
||||
"Exported {} preset{}": "{}件のプリセットを書き出しました{:.0}",
|
||||
"Exporting {} of {total}": "書き出し中:{total}枚中{}枚",
|
||||
"Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}",
|
||||
"Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}",
|
||||
"Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました{:.0}",
|
||||
"Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}",
|
||||
"Imported {} photo{}": "{}枚を読み込みました{:.0}",
|
||||
"Matches: {}": "一致条件:{}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%",
|
||||
"Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",
|
||||
"Named ({named_n})": "名前付き({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)",
|
||||
"Preset: {name}": "プリセット:{name}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({})",
|
||||
"Preview only — {}": "プレビューのみ:{}",
|
||||
"Profile: {}": "プロファイル:{}",
|
||||
"Proof Copy ({})": "校正用コピー({})",
|
||||
"Proof Preview · {}": "校正プレビュー・{}",
|
||||
"Quick Develop · {n} photos": "クイック現像・{n}枚",
|
||||
"Rated {}": "評価:{}",
|
||||
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
|
||||
"Redo {}": "やり直す:{}",
|
||||
"Remove “{}” from Favorites": "「{}」をお気に入りから削除",
|
||||
"Rename {n} Photos…": "{n}枚の写真の名前を変更…",
|
||||
"Rename “{name}”": "「{name}」の名前を変更",
|
||||
"Rendering on the CPU: {why}": "CPUで描画:{why}",
|
||||
"Reset {} Channel": "{}チャンネルをリセット",
|
||||
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
|
||||
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
|
||||
"Showing {n} of {}": "{}枚中{n}枚を表示",
|
||||
"Smart album: {}": "スマートアルバム:{}",
|
||||
"Undo {}": "取り消す:{}",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ",
|
||||
"Version: {}": "バージョン:{}",
|
||||
"none (CPU rendering): {why}": "なし(CPU描画):{why}",
|
||||
"the {n} selected photos": "選択中の{n}枚",
|
||||
"the {} photos in view": "表示中の{}枚",
|
||||
"these {count} photos": "この{count}枚の写真",
|
||||
"{count} smart preview{} · {:.1} MB": "スマートプレビュー{count}枚{:.0}・{:.1}MB",
|
||||
"{f} folders (and their subfolders)": "{f}個のフォルダとそのサブフォルダ",
|
||||
"{label} Grading": "{label}のカラーグレーディング",
|
||||
"{label} {d:+} on every selected photo": "選択したすべての写真の{label}を{d:+}調整",
|
||||
"{label}, {n} photos": "{label}、{n}枚",
|
||||
"{matching} of {t} photos": "{t}枚中{matching}枚",
|
||||
"{matching} photos": "{matching}枚",
|
||||
"{mode} spot {} of {}": "{mode}:{1}か所中{0}か所",
|
||||
"{msg} ({failed} failed)": "{msg}({failed}件失敗)",
|
||||
"{name} Label": "{name}のラベル",
|
||||
"{name} Off": "{name}を無効にする",
|
||||
"{name} On": "{name}を有効にする",
|
||||
"{name} — double-click to reset it": "{name}:ダブルクリックでリセット",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n}件の変更{:.0}をディスクに保存できません:{e}\n自動で再試行します。終了すると変更が失われます{:.0}",
|
||||
"{n} found · {sel} selected": "{n}枚検出・{sel}枚選択",
|
||||
"{n} photos": "{n}枚",
|
||||
"{n} photos in {} are hidden by the filters above": "{}の{n}枚は上のフィルターで非表示になっています",
|
||||
"{n} photo{}": "{n}枚{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n}枚が一致{:.0}・写真の変更に応じて自動更新",
|
||||
"{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新",
|
||||
"{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
"{what} merge added": "{what}合成を追加しました",
|
||||
"{what} merge failed: {e}": "{what}合成に失敗:{e}",
|
||||
"{} and higher": "{}以上",
|
||||
"{} files": "{}個のファイル",
|
||||
"{} files and {f} folder{}": "{}個のファイルと{f}個のフォルダ{:.0}",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{}本:垂直または水平にする線に沿ってドラッグ",
|
||||
"{} photos": "{}枚",
|
||||
"{} photo{} not readable": "{}枚を読み込めません{:.0}",
|
||||
"{} spot(s) on this photo": "この写真に{}か所のスポット",
|
||||
"· {dups} already in the library": "・{dups}枚は読み込み済み",
|
||||
"· {n} photo{}": "・{n}枚{:.0}",
|
||||
"… and {more} more": "…ほか{more}件",
|
||||
"Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}",
|
||||
"Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
|
||||
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -82,9 +82,14 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
||||
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
|
||||
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
|
||||
"status": app.ui.status,
|
||||
"notices": app.notices,
|
||||
"quitPrompt": app.quit_prompt,
|
||||
"unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})),
|
||||
"libraryProblem": app.library_problem.as_ref().map(crate::panels::library_problem::LibraryProblem::to_json),
|
||||
"scan": app.scan.as_ref().map(crate::import::ScanTask::status),
|
||||
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
|
||||
"import": app.import.as_ref().map(crate::import::ImportTask::status),
|
||||
"tasks": app.tasks.labels(),
|
||||
"memory": memory(app),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -176,6 +176,7 @@ impl Headless {
|
||||
|| self.app.scan.is_some()
|
||||
|| self.app.import.is_some()
|
||||
|| self.app.export.is_some()
|
||||
|| !self.app.tasks.is_empty()
|
||||
|| !self.app.synthetic.is_empty()
|
||||
|| !self.events.is_empty()
|
||||
}
|
||||
|
||||
+125
-117
@@ -1,24 +1,27 @@
|
||||
//! Interface translations. Command ids, document text and file names remain stable.
|
||||
//! Untranslated labels fall back to English so coverage can grow incrementally.
|
||||
//! Presentation-only localization. Command ids, user names and catalog data remain unchanged.
|
||||
use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/ja-formats.rs"));
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
#[serde(rename = "en")]
|
||||
En,
|
||||
#[serde(rename = "ja")]
|
||||
Ja,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "English",
|
||||
Self::Ja => "日本語",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(code: &str) -> Option<Self> {
|
||||
match code {
|
||||
"en" => Some(Self::En),
|
||||
@@ -26,129 +29,134 @@ impl Language {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tr(self, text: &str) -> &str {
|
||||
if self == Self::Ja
|
||||
&& let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text)
|
||||
{
|
||||
return japanese;
|
||||
}
|
||||
text
|
||||
pub fn tr(self, source: &str) -> &str {
|
||||
if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
|
||||
}
|
||||
}
|
||||
|
||||
const JAPANESE: &[(&str, &str)] = &[
|
||||
("Photo", "写真"),
|
||||
("Import Photos…", "写真を読み込み…"),
|
||||
("Detail", "詳細"),
|
||||
("Photo Grid", "写真グリッド"),
|
||||
("Square Grid", "正方形グリッド"),
|
||||
("Object", "オブジェクト"),
|
||||
("Effect", "効果"),
|
||||
("Settings…", "環境設定…"),
|
||||
("Image", "画像"),
|
||||
("Layer", "レイヤー"),
|
||||
("Type", "書式"),
|
||||
("Select", "選択"),
|
||||
("Filter", "フィルター"),
|
||||
("Window", "ウィンドウ"),
|
||||
("Language", "表示言語"),
|
||||
("Save As…", "別名で保存…"),
|
||||
("Exit", "終了"),
|
||||
("New…", "新規…"),
|
||||
("New", "新規"),
|
||||
("Horizontal", "横書き"),
|
||||
("Vertical", "縦書き"),
|
||||
("Orientation", "組み方向"),
|
||||
("Layers", "レイヤー"),
|
||||
("History", "履歴"),
|
||||
("Properties", "プロパティ"),
|
||||
("Color", "カラー"),
|
||||
("Brush Settings", "ブラシ設定"),
|
||||
("Tools", "ツール"),
|
||||
("Options", "オプション"),
|
||||
("Zoom In", "ズームイン"),
|
||||
("Zoom Out", "ズームアウト"),
|
||||
("Fit on Screen", "画面に合わせる"),
|
||||
("Copy", "コピー"),
|
||||
("Cut", "切り取り"),
|
||||
("Paste", "貼り付け"),
|
||||
("Select All", "すべて選択"),
|
||||
("Deselect", "選択を解除"),
|
||||
("Export", "書き出し"),
|
||||
("Export As…", "形式を指定して書き出し…"),
|
||||
("Search…", "検索…"),
|
||||
("Theme", "テーマ"),
|
||||
("Menu", "メニュー"),
|
||||
("File", "ファイル"),
|
||||
("Edit", "編集"),
|
||||
("Pages", "ページ"),
|
||||
("View", "表示"),
|
||||
("Help", "ヘルプ"),
|
||||
("Preferences", "環境設定"),
|
||||
("Preferences…", "環境設定…"),
|
||||
("Interface language", "表示言語"),
|
||||
("Open…", "開く…"),
|
||||
("New blank PDF", "空白の PDF を作成"),
|
||||
("Create PDF from file…", "ファイルから PDF を作成…"),
|
||||
("Create PDF from images…", "画像から PDF を作成…"),
|
||||
("Create PDF from clipboard", "クリップボードから PDF を作成"),
|
||||
("Combine files…", "ファイルを結合…"),
|
||||
("Save", "保存"),
|
||||
("Save as…", "別名で保存…"),
|
||||
("Close file", "ファイルを閉じる"),
|
||||
("Close all", "すべて閉じる"),
|
||||
("Revert", "保存済みの状態に戻す"),
|
||||
("Print…", "印刷…"),
|
||||
("Document properties…", "文書のプロパティ…"),
|
||||
("Undo", "取り消し"),
|
||||
("Redo", "やり直し"),
|
||||
("Find…", "検索…"),
|
||||
("Advanced search…", "高度な検索…"),
|
||||
("Copy pages", "ページをコピー"),
|
||||
("Cut pages", "ページを切り取り"),
|
||||
("Paste pages", "ページを貼り付け"),
|
||||
("Fit visible", "表示範囲に合わせる"),
|
||||
("Marquee zoom", "範囲指定ズーム"),
|
||||
("Take a snapshot", "スナップショットを作成"),
|
||||
("Full screen mode", "全画面表示"),
|
||||
("Read mode", "閲覧モード"),
|
||||
("Switch light / dark theme", "明るい/暗いテーマを切り替え"),
|
||||
("Comments panel", "コメントパネル"),
|
||||
("Form fields panel", "フォームフィールドパネル"),
|
||||
("Clear form", "フォームをクリア"),
|
||||
("Find tools and commands…", "ツールとコマンドを検索…"),
|
||||
("Zoom", "ズーム"),
|
||||
("Actual size", "実際のサイズ"),
|
||||
("Zoom to page level", "ページ全体を表示"),
|
||||
("Fit to width", "幅に合わせる"),
|
||||
("Display theme", "表示テーマ"),
|
||||
("Side panels", "サイドパネル"),
|
||||
("OK", "OK"),
|
||||
];
|
||||
thread_local! {
|
||||
static LANGUAGE: Cell<Language> = const { Cell::new(Language::En) };
|
||||
}
|
||||
|
||||
pub fn default_language() -> Language {
|
||||
if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En }
|
||||
}
|
||||
|
||||
pub fn set_language(language: Language) {
|
||||
LANGUAGE.with(|value| value.set(language));
|
||||
}
|
||||
|
||||
pub fn is_japanese() -> bool {
|
||||
LANGUAGE.with(|value| value.get() == Language::Ja)
|
||||
}
|
||||
|
||||
fn japanese() -> &'static BTreeMap<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 language_setting_persists() {
|
||||
let state = crate::state::UiState { language: Language::Ja, ..Default::default() };
|
||||
let saved = serde_json::to_string(&state).unwrap();
|
||||
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, Language::Ja);
|
||||
assert_eq!(crate::state::UiState::default().language, Language::En);
|
||||
fn catalog_is_valid_and_contains_core_workflows() {
|
||||
let messages: BTreeMap<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 translations_are_unique_and_preserve_unknown_text() {
|
||||
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
|
||||
assert!(!ja.is_empty());
|
||||
assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other));
|
||||
assert_eq!(Language::En.tr(en), *en);
|
||||
fn language_switches_and_unknown_text_survives() {
|
||||
set_language(Language::Ja);
|
||||
assert_eq!(tr("Exposure"), "露出");
|
||||
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
|
||||
assert_eq!(tr("develop.set"), "develop.set");
|
||||
assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color");
|
||||
assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color");
|
||||
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー");
|
||||
set_language(Language::En);
|
||||
assert_eq!(tr("Exposure"), "Exposure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() {
|
||||
set_language(Language::Ja);
|
||||
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚");
|
||||
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました");
|
||||
set_language(Language::En);
|
||||
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_round_trip_and_old_settings_remain_readable() {
|
||||
let old: crate::state::UiState = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(old.language, Language::En);
|
||||
let settings = crate::state::UiState { language: Language::Ja, ..old };
|
||||
let saved = serde_json::to_string(&settings).unwrap();
|
||||
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, Language::Ja);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_is_painted_and_both_font_weights_cover_the_catalog() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
app.ui.language = Language::Ja;
|
||||
app.ui.left_panel = true;
|
||||
let mut text = String::new();
|
||||
fn collect(shape: &egui::epaint::Shape, text: &mut String) {
|
||||
match shape {
|
||||
egui::epaint::Shape::Text(shape) => {
|
||||
text.push_str(&shape.galley.job.text);
|
||||
text.push('\n');
|
||||
}
|
||||
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(Language::Ja.tr("File"), "ファイル");
|
||||
assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf");
|
||||
assert_eq!(Language::parse("xx"), None);
|
||||
for frame in 0..4 {
|
||||
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
// This assertion inspects shapes without a renderer; discard texture uploads explicitly.
|
||||
out.textures_delta.clear();
|
||||
text.clear();
|
||||
for shape in out.shapes {
|
||||
collect(&shape.shape, &mut text);
|
||||
}
|
||||
}
|
||||
assert!(text.contains("マイフォト"), "{text}");
|
||||
assert!(text.contains("すべての写真"), "{text}");
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
|
||||
let font = egui::FontId::new(13.0, family);
|
||||
for message in japanese().values() {
|
||||
for ch in message.chars().filter(|ch| !ch.is_whitespace()) {
|
||||
assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Locale affects presentation only: command ids remain the same.
|
||||
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
|
||||
let japanese_ids = ids(&app);
|
||||
set_language(Language::En);
|
||||
assert_eq!(japanese_ids, ids(&app));
|
||||
}
|
||||
}
|
||||
+334
-74
@@ -3,8 +3,9 @@
|
||||
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked), the destination (add in
|
||||
//! place / copy or move into the library's `Originals/` or a chosen folder, filed by day, by month,
|
||||
//! into one folder or by a custom folder template, optionally renamed), an album
|
||||
//! (existing or new), a preset and keywords to apply. Importing runs in small batches, one per
|
||||
//! frame, with a progress window; the whole import is one undo step.
|
||||
//! (existing or new), a preset and keywords to apply. Importing runs on a worker thread (files are
|
||||
//! probed, copied or moved there) and its batches join the catalog between frames, with a progress
|
||||
//! window and Cancel; the whole import is one undo step.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with};
|
||||
@@ -17,7 +18,7 @@ use crate::render::Slot;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Files per batch (one batch per frame, so the progress window updates).
|
||||
/// Files per batch (each batch joins the catalog as it is ready, so the progress window updates).
|
||||
pub(crate) const BATCH: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -91,9 +92,13 @@ impl ImportDialog {
|
||||
}
|
||||
}
|
||||
|
||||
/// A running import (see the module docs).
|
||||
/// A running import (see the module docs). The file-system work — expanding folders, probing,
|
||||
/// reading sidecars, copying or placing files — runs on a worker thread
|
||||
/// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the
|
||||
/// UI thread between frames, so a slow drive never stalls the window.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ImportTask {
|
||||
/// Files and folders to import (handed to the worker when the task starts).
|
||||
queue: Vec<String>,
|
||||
pub total: usize,
|
||||
pub done: usize,
|
||||
@@ -109,6 +114,156 @@ pub struct ImportTask {
|
||||
/// Reading a folder for the Local view: the photos stay out of the library, and nothing is
|
||||
/// selected or announced as added.
|
||||
browse: bool,
|
||||
/// Cancel was pressed: no further files are started; batches already readied are added.
|
||||
pub cancelled: bool,
|
||||
/// Auto Import (the watched folder): the selection stays as it is, a short toast when done.
|
||||
auto: bool,
|
||||
/// Auto Import: the selection to keep.
|
||||
keep_selection: Option<lightcraft_engine::Selection>,
|
||||
run: Option<ImportRun>,
|
||||
}
|
||||
|
||||
impl ImportTask {
|
||||
/// An import of `paths` (files or folders) with `library.import` params (without `paths`).
|
||||
pub fn new(paths: Vec<String>, params: Value, undo0: usize, browse: bool) -> Self {
|
||||
ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() }
|
||||
}
|
||||
|
||||
/// An Auto Import (see [`ImportTask::auto`]).
|
||||
pub fn auto(mut self) -> Self {
|
||||
self.auto = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// `{done, total, imported, cancelled}` for `ui.inspect`.
|
||||
pub fn status(&self) -> Value {
|
||||
json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no
|
||||
/// slow drives) inline, one batch per frame.
|
||||
enum Runner {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Inline { job: Box<lightcraft_engine::import::ImportJob>, queue: Vec<String>, files: Option<std::collections::VecDeque<String>> },
|
||||
}
|
||||
|
||||
struct ImportRun {
|
||||
runner: Runner,
|
||||
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
opts: lightcraft_engine::import::ImportOptions,
|
||||
now: String,
|
||||
album: Option<u64>,
|
||||
album_name: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ImportRun {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ImportRun {
|
||||
/// A dropped import (replaced, or the app closing) stops its worker at the next file.
|
||||
fn drop(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportRun {
|
||||
fn start(
|
||||
job: lightcraft_engine::import::ImportJob,
|
||||
queue: Vec<String>,
|
||||
album: Option<u64>,
|
||||
album_name: Option<String>,
|
||||
ctx: &egui::Context,
|
||||
) -> Result<Self, String> {
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let (total, done) = (job.total.clone(), job.done.clone());
|
||||
let (opts, now) = (job.opts.clone(), job.now().to_string());
|
||||
let runner = Self::spawn(job, queue, cancel.clone(), ctx)?;
|
||||
Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name })
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn(
|
||||
mut job: lightcraft_engine::import::ImportJob,
|
||||
queue: Vec<String>,
|
||||
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
ctx: &egui::Context,
|
||||
) -> Result<Runner, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ctx = ctx.clone();
|
||||
let work = move || {
|
||||
let files = job.expand(&queue);
|
||||
for chunk in files.chunks(BATCH) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) {
|
||||
Ok(p) => p,
|
||||
Err(_) => break, // logged; the files readied so far are added
|
||||
};
|
||||
if let Err(unsent) = tx.send(prepared) {
|
||||
// the import was dropped: nothing will add these, so take back what a Move placed
|
||||
unsent.0.rollback();
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
};
|
||||
std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn spawn(
|
||||
job: lightcraft_engine::import::ImportJob,
|
||||
queue: Vec<String>,
|
||||
_cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
_ctx: &egui::Context,
|
||||
) -> Result<Runner, String> {
|
||||
Ok(Runner::Inline { job: Box::new(job), queue, files: None })
|
||||
}
|
||||
|
||||
/// Readied batches (at most a few per frame). `None` once the worker is done.
|
||||
fn next_batches(&mut self) -> Option<Vec<lightcraft_engine::import::Prepared>> {
|
||||
match &mut self.runner {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Runner::Thread(rx) => {
|
||||
let mut out = Vec::new();
|
||||
while out.len() < 4 {
|
||||
match rx.try_recv() {
|
||||
Ok(p) => out.push(p),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) },
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Runner::Inline { job, queue, files } => {
|
||||
if self.cancel.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
if files.is_none() {
|
||||
*files = Some(job.expand(queue).into());
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let Some(files) = files.as_mut() else { return None };
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let n = files.len().min(BATCH);
|
||||
let batch: Vec<String> = files.drain(..n).collect();
|
||||
Some(vec![job.prepare_files(batch, &self.cancel)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder scan running on a worker thread (a network share can take minutes to read; the
|
||||
@@ -232,7 +387,8 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if !queue.is_empty() {
|
||||
let undo0 = app.session.undo.len();
|
||||
let total = queue.len();
|
||||
app.import = Some(ImportTask { queue, total, params: json!({"mode": "add", "local": true}), undo0, browse: true, ..Default::default() });
|
||||
let _ = total;
|
||||
app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -256,19 +412,21 @@ pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let text = if total == 0 {
|
||||
if task.browse { "Reading folder…" } else { "Looking for photos…" }.to_string()
|
||||
} else {
|
||||
format!("Reading photos… {done} of {total}")
|
||||
crate::i18n::tr_format!("Reading photos… {done} of {total}", done = done, total = total)
|
||||
};
|
||||
let mut cancel = false;
|
||||
egui::Window::new("Scanning").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 80.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Window::new(crate::i18n::tr("Scanning"))
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_BOTTOM, [0.0, -80.0])
|
||||
.fixed_size([340.0, 80.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(320.0));
|
||||
let r = ui.button("Cancel");
|
||||
let r = ui.button(crate::i18n::tr("Cancel"));
|
||||
register(ui.ctx(), "button:scanCancel", r.rect);
|
||||
cancel = r.clicked();
|
||||
},
|
||||
);
|
||||
});
|
||||
if cancel {
|
||||
// the worker stops at its next file; don't wait for it (a NAS read can take a while)
|
||||
task.progress.cancel.store(true, Ordering::Relaxed);
|
||||
@@ -326,28 +484,82 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
|
||||
}
|
||||
}
|
||||
let total = queue.len();
|
||||
app.import = Some(ImportTask { queue, total, params, undo0, ..Default::default() });
|
||||
app.import = Some(ImportTask::new(queue, params, undo0, false));
|
||||
app.renderer.forget_imports();
|
||||
Ok(json!({"importing": total}))
|
||||
}
|
||||
|
||||
/// Run one batch of the import in progress (called every frame).
|
||||
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = app.import.as_mut() else { return };
|
||||
// (browsing reads a sidecar per file, slow on a network share: keep each frame short)
|
||||
let n = task.queue.len().min(if task.browse { 2 } else { BATCH });
|
||||
let batch: Vec<String> = task.queue.drain(..n).collect();
|
||||
let mut p = task.params.clone();
|
||||
p["paths"] = json!(batch);
|
||||
// renamed copies keep counting across batches
|
||||
if p.get("rename").is_some() {
|
||||
p["renameStart"] = json!(1 + task.imported);
|
||||
/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window.
|
||||
pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
|
||||
if app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) {
|
||||
return Err("an import is running".into());
|
||||
}
|
||||
let r = app.session.execute("library.import", &p);
|
||||
let Some(task) = app.import.as_mut() else { return };
|
||||
task.done += n;
|
||||
let undo0 = app.session.undo.len();
|
||||
let total = paths.len();
|
||||
app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false));
|
||||
Ok(json!({"importing": total}))
|
||||
}
|
||||
|
||||
/// Advance the import in progress (called every frame): start its worker, then add the batches it
|
||||
/// has readied to the catalog.
|
||||
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut task) = app.import.take() else { return };
|
||||
if task.run.is_none() {
|
||||
let mut p = task.params.clone();
|
||||
p["paths"] = json!(task.queue);
|
||||
let started = lightcraft_engine::cmd::library::import_params(&app.session, &p)
|
||||
.and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name)))
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx));
|
||||
if task.auto {
|
||||
task.keep_selection = Some(app.session.selection.clone());
|
||||
}
|
||||
match started {
|
||||
Ok(run) => task.run = Some(run),
|
||||
Err(e) => {
|
||||
log::warn!("import: {e}");
|
||||
app.toast(ctx, format!("Import failed: {e}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
let Some(run) = task.run.as_mut() else { return };
|
||||
let batches = run.next_batches();
|
||||
let finished = batches.is_none();
|
||||
for prepared in batches.into_iter().flatten() {
|
||||
commit_batch(app, &mut task, prepared);
|
||||
// arriving photos don't take over the selection
|
||||
if let Some(sel) = &task.keep_selection {
|
||||
app.session.selection = sel.clone();
|
||||
}
|
||||
}
|
||||
if let Some(run) = &task.run {
|
||||
task.total = run.total.load(Ordering::Relaxed).max(task.total);
|
||||
task.done = run.done.load(Ordering::Relaxed);
|
||||
}
|
||||
if !finished {
|
||||
app.import = Some(task);
|
||||
return;
|
||||
}
|
||||
finish(app, ctx, task);
|
||||
}
|
||||
|
||||
/// Add one readied batch to the catalog (an undo step merged into the import's at the end).
|
||||
fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) {
|
||||
let Some(run) = task.run.as_ref() else { return };
|
||||
let n = prepared.len();
|
||||
let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone());
|
||||
let r = app.session.execute_fn("library.import", |s| {
|
||||
let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?;
|
||||
lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref())
|
||||
});
|
||||
match r {
|
||||
Ok(v) => {
|
||||
// a new album (named in the params) is created with the first photos; later batches join it
|
||||
if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) {
|
||||
run.album = Some(a);
|
||||
}
|
||||
let len = |k: &str| v[k].as_array().map_or(0, Vec::len);
|
||||
task.imported += len("imported");
|
||||
task.duplicates += len("duplicates");
|
||||
@@ -366,17 +578,22 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
task.failed += n;
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
if !task.queue.is_empty() {
|
||||
}
|
||||
|
||||
/// The import is done (or cancelled): one undo step, select the first photo, say what happened.
|
||||
fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
||||
let steps = app.session.undo.len().saturating_sub(task.undo0);
|
||||
let label = crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
|
||||
app.session.merge_undo(steps, &label);
|
||||
if task.auto {
|
||||
if task.imported > 0 {
|
||||
app.toast(ctx, format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
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" });
|
||||
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,35 +601,58 @@ 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 = if task.cancelled {
|
||||
crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported))
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
/// The progress window while an import runs.
|
||||
/// The progress window while an import runs, with Cancel (files already copied or added stay;
|
||||
/// nothing new is started).
|
||||
pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = &app.import else { return };
|
||||
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| {
|
||||
let text = if task.cancelled {
|
||||
crate::i18n::tr("Stopping…").to_string()
|
||||
} else if task.browse {
|
||||
crate::i18n::tr_format!("Reading photos… {} of {}", task.done, task.total)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Adding photos… {} of {}", task.done, task.total)
|
||||
};
|
||||
let mut cancel = false;
|
||||
egui::Window::new(crate::i18n::tr("Importing"))
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_BOTTOM, [0.0, -80.0])
|
||||
.fixed_size([340.0, 80.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
ui.add(egui::ProgressBar::new(frac).desired_width(320.0));
|
||||
},
|
||||
);
|
||||
let r = ui.add_enabled(!task.cancelled, egui::Button::new(crate::i18n::tr("Cancel")));
|
||||
register(ui.ctx(), "button:importCancel", r.rect);
|
||||
cancel = r.clicked();
|
||||
});
|
||||
if cancel && let Some(task) = app.import.as_mut() {
|
||||
task.cancelled = true;
|
||||
if let Some(run) = &task.run {
|
||||
run.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog body: options, then the candidate grid.
|
||||
@@ -422,15 +662,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count();
|
||||
let sel = d.selected_paths().len();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(format!("{n} found · {sel} selected")).color(t.text));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text));
|
||||
if dups > 0 {
|
||||
ui.label(egui::RichText::new(format!("· {dups} already in the library")).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::widgets::text_button(ui, "importNone", "Uncheck All", false).clicked() {
|
||||
if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() {
|
||||
d.checked.iter_mut().for_each(|c| *c = false);
|
||||
}
|
||||
if crate::widgets::text_button(ui, "importAll", "Check All", false).clicked() {
|
||||
if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() {
|
||||
for i in 0..n {
|
||||
d.checked[i] = d.importable(i);
|
||||
}
|
||||
@@ -458,29 +698,42 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
// options
|
||||
if !d.sources.is_empty() {
|
||||
field(ui, "Source", |ui| {
|
||||
let r = ui.label(egui::RichText::new(source_summary(&d.sources)).color(t.text_label)).on_hover_text(d.sources.join("\n"));
|
||||
// (whether each source is a folder is checked off the UI thread, once)
|
||||
let joined = d.sources.join("\n");
|
||||
let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| {
|
||||
source_summary(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
|
||||
})
|
||||
.unwrap_or_else(|| format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
|
||||
let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined);
|
||||
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 +758,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() };
|
||||
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
|
||||
if app.services.pick_folder.is_some()
|
||||
&& crate::widgets::text_button(ui, "importDest", "Choose…", false).clicked()
|
||||
&& crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked()
|
||||
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
|
||||
{
|
||||
d.destination = f;
|
||||
}
|
||||
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", "Library", false).clicked() {
|
||||
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() {
|
||||
d.destination.clear();
|
||||
}
|
||||
});
|
||||
@@ -557,14 +810,16 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
None => unknown_tags_warning(ui, &d.folder_template),
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new("Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").")
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
egui::RichText::new(crate::i18n::tr(
|
||||
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").",
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
}
|
||||
if !d.move_files {
|
||||
field(ui, "Raw files", |ui| {
|
||||
let r = ui.checkbox(&mut d.dng, "Copy as DNG");
|
||||
let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG"));
|
||||
register(ui.ctx(), "check:importDng", r.rect);
|
||||
});
|
||||
}
|
||||
@@ -582,10 +837,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
unknown_tags_warning(ui, &d.rename);
|
||||
if let Some(example) = example_destination(app, d) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let r = ui.label(egui::RichText::new(format!("Example: {example}")).color(t.text_dim));
|
||||
let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim));
|
||||
register(ui.ctx(), "label:importExample", r.rect);
|
||||
ui.label(
|
||||
egui::RichText::new("Folders and {date} use the capture time; a photo without one uses today's date.").color(t.text_dim).small(),
|
||||
egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date."))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -602,11 +859,11 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
None => "None".into(),
|
||||
};
|
||||
egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), "None").clicked() {
|
||||
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() {
|
||||
d.album = None;
|
||||
d.new_album.clear();
|
||||
}
|
||||
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), "New album…").clicked() {
|
||||
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() {
|
||||
d.album = None;
|
||||
if d.new_album.is_empty() {
|
||||
d.new_album = "Imported Photos".into();
|
||||
@@ -627,7 +884,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
field(ui, "Preset", |ui| {
|
||||
let cur = app.session.presets.iter().find(|p| p.id == d.preset).map(|p| p.name.clone()).unwrap_or_else(|| "None".into());
|
||||
egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(d.preset.is_empty(), "None").clicked() {
|
||||
if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() {
|
||||
d.preset.clear();
|
||||
}
|
||||
for p in &app.session.presets {
|
||||
@@ -641,7 +898,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
field(ui, "Metadata", |ui| {
|
||||
let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() };
|
||||
egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(d.metadata_preset.is_empty(), "None").clicked() {
|
||||
if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() {
|
||||
d.metadata_preset.clear();
|
||||
}
|
||||
for m in &app.session.metadata_presets {
|
||||
@@ -653,7 +910,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
});
|
||||
}
|
||||
field(ui, "Keywords", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text("comma, separated").desired_width(f32::INFINITY));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY));
|
||||
register(ui.ctx(), "field:importKeywords", r.rect);
|
||||
});
|
||||
}
|
||||
@@ -726,7 +983,8 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
|
||||
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
|
||||
let id = egui::Id::new((key, "tags-open"));
|
||||
let mut open = ui.ctx().data(|d| d.get_temp::<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 +1028,9 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id:
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.").color(t.text_dim).small(),
|
||||
egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48."))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| {
|
||||
egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| {
|
||||
@@ -848,9 +1108,9 @@ pub fn source_summary(sources: &[String]) -> String {
|
||||
many => {
|
||||
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
|
||||
match folders {
|
||||
0 => format!("{} files", many.len()),
|
||||
f if f == many.len() => format!("{f} folders (and their subfolders)"),
|
||||
f => format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }),
|
||||
0 => crate::i18n::tr_format!("{} files", many.len()),
|
||||
f if f == many.len() => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
|
||||
f => crate::i18n::tr_format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }, f = f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+89
-11
@@ -21,6 +21,7 @@ pub mod render;
|
||||
pub mod shortcuts;
|
||||
pub mod softpaint;
|
||||
pub mod state;
|
||||
pub mod tasks;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
@@ -29,10 +30,16 @@ mod tests_curve;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_library_problem;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
#[cfg(test)]
|
||||
mod tests_offline;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
#[cfg(test)]
|
||||
mod tests_quit_unsaved;
|
||||
#[cfg(test)]
|
||||
mod tests_scroll;
|
||||
#[cfg(test)]
|
||||
mod tests_unsaved;
|
||||
@@ -60,6 +67,9 @@ pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
pub type OpenUrlFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
/// Open a file in an application (`app` = "" for the system's default one).
|
||||
pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
|
||||
/// Something the host does with the session (web: back up or restore the library in browser
|
||||
/// storage); the work may finish asynchronously.
|
||||
pub type HostAction = Box<dyn FnMut(&mut Session) -> Result<Value, String>>;
|
||||
|
||||
/// Platform services injected by the host app (desktop or web).
|
||||
#[derive(Default)]
|
||||
@@ -89,6 +99,12 @@ pub struct Services {
|
||||
pub open_url: Option<OpenUrlFn>,
|
||||
/// Open a file in an external editor (Edit in External Editor; desktop only).
|
||||
pub open_with: Option<OpenWithFn>,
|
||||
/// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the
|
||||
/// user keeps (web only: there the library lives in browser storage, which the browser may
|
||||
/// clear; on the desktop it is a folder backed up like any other).
|
||||
pub backup_library: Option<HostAction>,
|
||||
/// File ▸ Restore Library from Backup… (web only; keeps the current library).
|
||||
pub restore_library: Option<HostAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -123,6 +139,12 @@ pub struct LightcraftApp {
|
||||
pub native_shortcuts: std::collections::HashSet<String>,
|
||||
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
|
||||
pub headless_host: bool,
|
||||
/// Warnings to show one at a time (damaged settings files…, issue #103).
|
||||
pub notices: Vec<String>,
|
||||
/// Quitting was stopped because changes couldn't be saved: the prompt's text.
|
||||
pub quit_prompt: Option<String>,
|
||||
/// Quit Anyway was chosen: the window may close with unsaved changes.
|
||||
pub quit_confirmed: bool,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
pending_screenshots: Vec<PendingShot>,
|
||||
screenshot_token: u64,
|
||||
@@ -159,6 +181,8 @@ pub struct LightcraftApp {
|
||||
pub scan: Option<import::ScanTask>,
|
||||
/// A background export in progress.
|
||||
pub export: Option<export_task::ExportTask>,
|
||||
/// Background file-system work of other commands (Find Missing Photos, auto import…).
|
||||
pub tasks: tasks::Tasks,
|
||||
/// The files of the last finished background export (`ui.inspect` → `export.last`).
|
||||
pub last_export_result: Option<Value>,
|
||||
/// The look the loupe shows while the pointer rests on a preset or profile (set by the
|
||||
@@ -170,6 +194,9 @@ pub struct LightcraftApp {
|
||||
gpu_applied: Option<bool>,
|
||||
/// The memory budget setting last applied (MB, 0 = automatic).
|
||||
memory_applied: Option<u32>,
|
||||
/// The library failed to open at launch: the blocking window, then the temporary-session
|
||||
/// banner (issue #100). Cleared once a library opens.
|
||||
pub library_problem: Option<panels::library_problem::LibraryProblem>,
|
||||
}
|
||||
|
||||
impl LightcraftApp {
|
||||
@@ -187,6 +214,9 @@ impl LightcraftApp {
|
||||
native_menu: false,
|
||||
native_shortcuts: Default::default(),
|
||||
headless_host: false,
|
||||
notices: vec![],
|
||||
quit_prompt: None,
|
||||
quit_confirmed: false,
|
||||
control_rx: None,
|
||||
pending_screenshots: vec![],
|
||||
screenshot_token: 0,
|
||||
@@ -208,11 +238,13 @@ impl LightcraftApp {
|
||||
import: None,
|
||||
scan: None,
|
||||
export: None,
|
||||
tasks: Default::default(),
|
||||
last_export_result: None,
|
||||
hover_preview: None,
|
||||
window_is_fullscreen: false,
|
||||
gpu_applied: None,
|
||||
memory_applied: None,
|
||||
library_problem: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +301,12 @@ impl LightcraftApp {
|
||||
if self.ui.preview_build_seen != Some((key, true)) {
|
||||
self.ui.preview_build_seen = Some((key, true));
|
||||
let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed));
|
||||
let mut msg = format!("Previews ready for {done} photo{}", if done == 1 { "" } else { "s" });
|
||||
let plural = if done == 1 { "" } else { "s" };
|
||||
let mut msg = match (b.error(), b.what) {
|
||||
(Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }),
|
||||
(None, "") => format!("Previews ready for {done} photo{plural}"),
|
||||
(None, what) => format!("Done ({what}): {done} photo{plural}"),
|
||||
};
|
||||
if failed > 0 {
|
||||
msg.push_str(&format!(" · {failed} couldn't be rendered"));
|
||||
}
|
||||
@@ -278,7 +315,11 @@ impl LightcraftApp {
|
||||
} else {
|
||||
if self.ui.preview_build_seen != Some((key, false)) {
|
||||
self.ui.preview_build_seen = Some((key, false));
|
||||
self.toast(ctx, format!("Building previews for {} photos…", b.total));
|
||||
let msg = match b.what {
|
||||
"" => format!("Building previews for {} photos…", b.total),
|
||||
what => format!("Working on {what} for {} photos…", b.total),
|
||||
};
|
||||
self.toast(ctx, msg);
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
@@ -439,7 +480,9 @@ impl LightcraftApp {
|
||||
|
||||
/// Per-frame logic before layout (control channel, renders, shortcuts, drops).
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
i18n::set_language(self.ui.language);
|
||||
let t0 = now_ms();
|
||||
panels::library_problem::logic(self);
|
||||
self.logic_inner(ctx);
|
||||
self.perf.logic_ms = now_ms() - t0;
|
||||
}
|
||||
@@ -451,10 +494,19 @@ impl LightcraftApp {
|
||||
// File → Add from Device lists cards scanned in the background: show hot-plugs
|
||||
let repaint = ctx.clone();
|
||||
lightcraft_engine::devices::on_change(move || repaint.request_repaint());
|
||||
// "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a
|
||||
// worker fills: a sleeping NAS or a dropped share never stalls a frame
|
||||
let repaint = ctx.clone();
|
||||
self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint()));
|
||||
self.styled = true;
|
||||
} else {
|
||||
self.fonts_ready = true;
|
||||
}
|
||||
panels::notices::logic(self);
|
||||
// closing the window (or Quit) with changes only in memory: retry, else ask first
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let dt = now - self.last_time;
|
||||
if dt > 0.0 {
|
||||
@@ -470,6 +522,7 @@ impl LightcraftApp {
|
||||
merge::poll(self, ctx);
|
||||
import::poll_scan(self, ctx);
|
||||
import::tick(self, ctx);
|
||||
tasks::poll(self, ctx);
|
||||
self.preview_build_status(ctx);
|
||||
self.save_status(ctx);
|
||||
self.slideshow_tick(ctx);
|
||||
@@ -484,17 +537,33 @@ impl LightcraftApp {
|
||||
}
|
||||
}
|
||||
self.ui.was_focused = focused;
|
||||
// auto import: scan the watched folder every few seconds
|
||||
// auto import: list the watched folder every few seconds (on a worker thread: it may be on
|
||||
// a network share), then import what's new like any import (also on a worker thread)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.session.import_defaults.auto_folder.is_some() {
|
||||
if let Some(folder) = self.session.import_defaults.auto_folder.clone() {
|
||||
const LABEL: &str = "Auto Import";
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now - self.ui.auto_import_at >= 3.0 {
|
||||
if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && !self.tasks.is_running(LABEL) {
|
||||
self.ui.auto_import_at = now;
|
||||
if let Ok(r) = self.session.execute("library.autoImportScan", &serde_json::json!({})) {
|
||||
let n = r["imported"].as_array().map_or(0, Vec::len);
|
||||
if n > 0 {
|
||||
self.toast(ctx, format!("Auto Import: added {n} photo{}", if n == 1 { "" } else { "s" }));
|
||||
let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);
|
||||
let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {
|
||||
let listing = match listing {
|
||||
Ok(l) => l,
|
||||
Err(e) => return log::debug!("auto import: {e}"),
|
||||
};
|
||||
let p = serde_json::json!({"listing": listing, "start": false});
|
||||
let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return };
|
||||
let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return };
|
||||
let paths: Vec<String> =
|
||||
params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("paths");
|
||||
}
|
||||
let undo0 = app.session.undo.len();
|
||||
app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto());
|
||||
};
|
||||
if let Err(e) = tasks::spawn(self, LABEL, work, done) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(3));
|
||||
@@ -514,8 +583,11 @@ impl LightcraftApp {
|
||||
if !presets.is_empty() {
|
||||
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
|
||||
}
|
||||
if !photos.is_empty() {
|
||||
let _ = self.run("library.import", serde_json::json!({"paths": photos}));
|
||||
// read and added on a worker thread (dropped folders can be large, or on a slow drive)
|
||||
if !photos.is_empty()
|
||||
&& let Err(e) = import::start_paths(self, photos)
|
||||
{
|
||||
self.toast(ctx, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +658,7 @@ impl LightcraftApp {
|
||||
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
i18n::set_language(self.ui.language);
|
||||
let ctx = ui.ctx().clone();
|
||||
if !self.fonts_ready {
|
||||
ctx.request_repaint();
|
||||
@@ -601,7 +674,9 @@ impl LightcraftApp {
|
||||
// full-screen preview: the photo alone on black
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui));
|
||||
panels::second::show(self, &ctx);
|
||||
panels::notices::show(self, &ctx);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::library_problem::show(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
self.end_frame(t0);
|
||||
@@ -610,6 +685,7 @@ impl LightcraftApp {
|
||||
// Order matters: earlier panels take the full edge (top bar spans the window; the tool strip,
|
||||
// right panels and left panel run to the bottom; the bottom bar sits between them).
|
||||
panels::topbar::show(self, ui);
|
||||
panels::library_problem::banner(self, ui);
|
||||
panels::strip::show(self, ui);
|
||||
if self.ui.right != state::RightPanel::None {
|
||||
panels::right::show(self, ui);
|
||||
@@ -636,7 +712,9 @@ impl LightcraftApp {
|
||||
state::ViewMode::Reference => panels::compare::show_reference(self, ui),
|
||||
});
|
||||
panels::second::show(self, &ctx);
|
||||
panels::notices::show(self, &ctx);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::library_problem::show(self, &ctx);
|
||||
import::progress(self, &ctx);
|
||||
import::scan_progress(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -58,6 +67,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"@Import from Device",
|
||||
"---",
|
||||
"app.openLibrary",
|
||||
"file.backupLibrary",
|
||||
"file.restoreLibrary",
|
||||
"---",
|
||||
"dialog.newAlbum",
|
||||
"dialog.newFolder",
|
||||
@@ -241,6 +252,15 @@ const HIDDEN: &[&str] = &[
|
||||
"preset.create",
|
||||
];
|
||||
|
||||
/// Items only some hosts have are left out of the others' menus (the web build's library backup).
|
||||
fn host_supports(app: &LightcraftApp, id: &str) -> bool {
|
||||
match id {
|
||||
"file.backupLibrary" => app.services.backup_library.is_some(),
|
||||
"file.restoreLibrary" => app.services.restore_library.is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode {
|
||||
MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked }
|
||||
}
|
||||
@@ -296,11 +316,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(),
|
||||
}
|
||||
}
|
||||
@@ -325,20 +349,21 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None),
|
||||
item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None),
|
||||
MenuNode::Separator,
|
||||
// (read and written on a worker thread: the originals may be on a slow drive)
|
||||
item(
|
||||
"library.smartPreviews",
|
||||
Value::Null,
|
||||
json!({"background": true}),
|
||||
format!("Build Smart Previews ({scope})"),
|
||||
None,
|
||||
app.session.media.smart_dir.is_some(),
|
||||
app.session.media.smart_dir.is_some() && !running,
|
||||
None,
|
||||
),
|
||||
item(
|
||||
"library.smartPreviews",
|
||||
json!({"discard": true}),
|
||||
json!({"discard": true, "background": true}),
|
||||
format!("Discard Smart Previews ({scope})"),
|
||||
None,
|
||||
app.session.media.smart_dir.is_some(),
|
||||
app.session.media.smart_dir.is_some() && !running,
|
||||
None,
|
||||
),
|
||||
MenuNode::Separator,
|
||||
@@ -464,14 +489,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
|
||||
}
|
||||
@@ -537,7 +569,8 @@ fn tidy(v: Vec<MenuNode>) -> Vec<MenuNode> {
|
||||
|
||||
/// The whole menu bar: (title, items) per menu in [`MENUS`] order.
|
||||
pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
|
||||
let entries: Vec<MenuEntry> = crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str())).collect();
|
||||
let entries: Vec<MenuEntry> =
|
||||
crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str()) && host_supports(app, &e.id)).collect();
|
||||
let mut used = vec![false; entries.len()];
|
||||
let mut bar = Vec::new();
|
||||
for title in MENUS {
|
||||
@@ -595,22 +628,6 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
|
||||
}
|
||||
bar.push((title.to_string(), tidy(items)));
|
||||
}
|
||||
fn translate(nodes: &mut [MenuNode], language: crate::i18n::Language) {
|
||||
for node in nodes {
|
||||
match node {
|
||||
MenuNode::Submenu { label, children } => {
|
||||
*label = language.tr(label).to_string();
|
||||
translate(children, language);
|
||||
}
|
||||
MenuNode::Item { label, .. } => *label = language.tr(label).to_string(),
|
||||
MenuNode::Separator => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (title, nodes) in &mut bar {
|
||||
*title = app.ui.language.tr(title).to_string();
|
||||
translate(nodes, app.ui.language);
|
||||
}
|
||||
bar
|
||||
}
|
||||
|
||||
@@ -664,9 +681,9 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String {
|
||||
const TITLE_GAP: f32 = 24.0;
|
||||
|
||||
/// Width of the in-window menu bar's titles.
|
||||
pub fn bar_width(ui: &egui::Ui, language: crate::i18n::Language) -> f32 {
|
||||
pub fn bar_width(ui: &egui::Ui) -> f32 {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
MENUS.iter().map(|m| ui.painter().layout_no_wrap(language.tr(m).to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::<f32>()
|
||||
MENUS.iter().map(|m| ui.painter().layout_no_wrap(crate::i18n::tr(m).to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::<f32>()
|
||||
}
|
||||
|
||||
/// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single
|
||||
@@ -675,8 +692,10 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let bar = menu_bar(app);
|
||||
let font = t.font(13.0);
|
||||
let widths: Vec<f32> =
|
||||
bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + TITLE_GAP).collect();
|
||||
let widths: Vec<f32> = bar
|
||||
.iter()
|
||||
.map(|(title, _)| ui.painter().layout_no_wrap(crate::i18n::tr(title).to_string(), font.clone(), t.text).size().x + TITLE_GAP)
|
||||
.collect();
|
||||
let total: f32 = widths.iter().sum();
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
let start = ui.cursor().left();
|
||||
@@ -684,18 +703,18 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
||||
if total <= max_width {
|
||||
let saved = ui.spacing().item_spacing.x;
|
||||
ui.spacing_mut().item_spacing.x = TITLE_GAP;
|
||||
for (index, (title, items)) in bar.iter().enumerate() {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), format!("menu:{}", MENUS.get(index).copied().unwrap_or(title.as_str())), r.rect);
|
||||
for (title, items) in &bar {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked));
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = saved;
|
||||
} else {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for (title, items) in &bar {
|
||||
ui.menu_button(title, |ui| nodes_ui(ui, items, mac, &mut clicked));
|
||||
ui.menu_button(crate::i18n::tr(title), |ui| nodes_ui(ui, items, mac, &mut clicked));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -713,11 +732,11 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti
|
||||
ui.separator();
|
||||
}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
ui.menu_button(format!(" {label}"), |ui| nodes_ui(ui, children, mac, clicked));
|
||||
ui.menu_button(format!(" {}", crate::i18n::tr(label)), |ui| nodes_ui(ui, children, mac, clicked));
|
||||
}
|
||||
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
|
||||
// a gutter for check marks, like native menus
|
||||
let mut b = egui::Button::new(format!(" {label}"));
|
||||
let mut b = egui::Button::new(format!(" {}", display_item_label(id, params, label)));
|
||||
if let Some(sc) = shortcut {
|
||||
b = b.shortcut_text(shortcut_text(sc, mac));
|
||||
}
|
||||
@@ -774,6 +793,34 @@ mod tests {
|
||||
assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus");
|
||||
}
|
||||
|
||||
/// Back Up / Restore Library are the browser build's (its library lives in browser storage):
|
||||
/// absent from the desktop's menus, present and wired to the host where it provides them.
|
||||
#[test]
|
||||
fn library_backup_items_follow_the_host() {
|
||||
let all = |app: &LightcraftApp| -> Vec<MenuNode> { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() };
|
||||
let mut desktop = app();
|
||||
assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none());
|
||||
assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host");
|
||||
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let c = calls.clone();
|
||||
let services = crate::Services {
|
||||
backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| {
|
||||
c.set(c.get() + 1);
|
||||
Ok(json!({"photos": s.catalog.len()}))
|
||||
})),
|
||||
restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let bar = all(&web);
|
||||
for id in ["file.backupLibrary", "file.restoreLibrary"] {
|
||||
assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}");
|
||||
}
|
||||
let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap();
|
||||
assert!(r["photos"].as_u64().unwrap() > 0);
|
||||
assert_eq!(calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_follows_the_registry_and_state() {
|
||||
let mut app = app();
|
||||
@@ -936,7 +983,7 @@ mod tests {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default());
|
||||
app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap();
|
||||
std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap();
|
||||
let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy()})).unwrap();
|
||||
let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy(), "wait": true})).unwrap();
|
||||
assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
// Locate: an explicit file
|
||||
std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap();
|
||||
|
||||
+59
-20
@@ -116,6 +116,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("file.addFolder", "Import from Folder…", None, "File"),
|
||||
("file.addFromDevice", "Import from Device", None, ""),
|
||||
("file.findMissing", "Find Missing Photos…", None, "File"),
|
||||
("file.backupLibrary", "Back Up Library…", None, "File"),
|
||||
("file.restoreLibrary", "Restore Library from Backup…", None, "File"),
|
||||
("photo.locate", "Locate Missing File…", None, ""),
|
||||
("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""),
|
||||
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
|
||||
@@ -149,10 +151,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
|
||||
if app.ui.right == p {
|
||||
app.ui.right = RightPanel::None;
|
||||
app.toast(ctx, format!("{name} Off"));
|
||||
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = name));
|
||||
} else {
|
||||
app.ui.right = p;
|
||||
app.toast(ctx, format!("{name} On"));
|
||||
app.toast(ctx, crate::i18n::tr_format!("{name} On", name = name));
|
||||
if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) {
|
||||
app.ui.view = ViewMode::Detail;
|
||||
}
|
||||
@@ -381,6 +383,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.openLibrary" => crate::panels::settings::open_library(app, p),
|
||||
"file.backupLibrary" | "file.restoreLibrary" => {
|
||||
let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() };
|
||||
match action {
|
||||
Some(f) => f(&mut app.session),
|
||||
None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()),
|
||||
}
|
||||
}
|
||||
"view.filmstrip" => {
|
||||
app.ui.filmstrip = !app.ui.filmstrip;
|
||||
Ok(Value::Null)
|
||||
@@ -865,7 +874,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(),
|
||||
}),
|
||||
),
|
||||
@@ -928,18 +937,40 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
None => app.services.pick_folder.as_mut().and_then(|f| f()),
|
||||
};
|
||||
let Some(folder) = folder else { return Some(Ok(Value::Null)) };
|
||||
let r = app.run("library.findMissing", json!({"folder": folder}));
|
||||
if let Ok(v) = &r {
|
||||
let n = v["found"].as_array().map_or(0, Vec::len);
|
||||
let left = v["missing"].as_u64().unwrap_or(0);
|
||||
let unsure = v["ambiguous"].as_array().map_or(0, Vec::len);
|
||||
let unsure = if unsure > 0 { format!(" ({unsure} with several look-alike files: use Locate)") } else { String::new() };
|
||||
app.toast(
|
||||
&egui::Context::default(),
|
||||
format!("Found {n} missing photo{}; {left} still missing{unsure}", if n == 1 { "" } else { "s" }),
|
||||
);
|
||||
// the search (checking every photo's file, walking the folder) runs on a worker thread;
|
||||
// the relinking happens back here, as one undo step
|
||||
const LABEL: &str = "Find Missing Photos";
|
||||
if app.tasks.is_running(LABEL) {
|
||||
return Some(Err("Find Missing Photos is already searching".into()));
|
||||
}
|
||||
r
|
||||
let candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog);
|
||||
let work = move || lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder);
|
||||
let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result<lightcraft_engine::cmd::missing::FindPlan, String>| {
|
||||
// relinked under the session as it is now: photos relinked meanwhile and files
|
||||
// now in use are skipped
|
||||
let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json()));
|
||||
match r {
|
||||
Ok(v) => {
|
||||
let n = v["found"].as_array().map_or(0, Vec::len);
|
||||
let left = v["missing"].as_u64().unwrap_or(0);
|
||||
let unsure = v["ambiguous"].as_array().map_or(0, Vec::len);
|
||||
let unsure = if unsure > 0 { format!(" ({unsure} with several look-alike files: use Locate)") } else { String::new() };
|
||||
app.toast(ctx, format!("Found {n} missing photo{}; {left} still missing{unsure}", if n == 1 { "" } else { "s" }));
|
||||
app.ui.last_find_missing = Some(v);
|
||||
}
|
||||
Err(e) => app.toast(ctx, e),
|
||||
}
|
||||
};
|
||||
app.ui.last_find_missing = None;
|
||||
if let Err(e) = crate::tasks::spawn(app, LABEL, work, done) {
|
||||
return Some(Err(e));
|
||||
}
|
||||
if p.get("wait").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let ctx = app.tasks.repaint.clone().unwrap_or_default();
|
||||
crate::tasks::wait(app, &ctx, std::time::Duration::from_secs(600));
|
||||
return Some(Ok(app.ui.last_find_missing.clone().unwrap_or(Value::Null)));
|
||||
}
|
||||
Ok(json!({"background": true}))
|
||||
}
|
||||
"photo.tagFromTracklog" => {
|
||||
// a GPX file → GPS for the selected photos by capture time (one undo step)
|
||||
@@ -1064,8 +1095,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"]
|
||||
@@ -1110,7 +1147,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
let r = app.session.execute("preset.export", ¶ms).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);
|
||||
}
|
||||
@@ -1129,9 +1166,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);
|
||||
}
|
||||
@@ -1152,7 +1189,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
let r = app.session.execute("curve.exportPresets", ¶ms).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);
|
||||
}
|
||||
@@ -1193,6 +1230,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
|
||||
"file.backupLibrary" => app.services.backup_library.is_some(),
|
||||
"file.restoreLibrary" => app.services.restore_library.is_some(),
|
||||
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
|
||||
s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => {
|
||||
app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none()
|
||||
|
||||
+13
-13
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,53 @@ use crate::LightcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Rows the Rename dialog previews.
|
||||
const RENAME_PREVIEW_ROWS: usize = 6;
|
||||
|
||||
/// The Rename dialog's preview: its first rows (`None` while being planned) and how many files
|
||||
/// are renamed. Which names are taken is checked on disk, which can block on a slow drive, so the
|
||||
/// rows are planned on a worker thread, and only when the template, start number, photos or
|
||||
/// catalog change — never once per frame.
|
||||
pub(crate) fn rename_preview(
|
||||
app: &mut LightcraftApp,
|
||||
ctx: &egui::Context,
|
||||
template: &str,
|
||||
start: usize,
|
||||
) -> (Option<Vec<lightcraft_engine::rename::RenamePlan>>, usize) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
type Rows = std::sync::Arc<std::sync::Mutex<Option<Vec<lightcraft_engine::rename::RenamePlan>>>>;
|
||||
let ids = app.session.targets(&json!({}));
|
||||
let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids);
|
||||
let total = photos.len();
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(template, start, app.session.catalog.revision, &ids).hash(&mut h);
|
||||
let key = h.finish();
|
||||
let id = egui::Id::new("rename-preview-rows");
|
||||
let read = |rows: &Rows| rows.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
|
||||
if let Some((k, rows)) = ctx.data(|d| d.get_temp::<(u64, Rows)>(id))
|
||||
&& k == key
|
||||
{
|
||||
return (read(&rows), total);
|
||||
}
|
||||
let rows: Rows = Default::default();
|
||||
let first: Vec<_> = photos.into_iter().take(RENAME_PREVIEW_ROWS).collect();
|
||||
let (out, template, repaint) = (rows.clone(), template.to_string(), ctx.clone());
|
||||
let exists = app.session.media.availability.probe();
|
||||
let work = move || {
|
||||
let plans = lightcraft_engine::rename::plan_rename_photos(&first, &template, start, &|f| exists(f));
|
||||
*out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(plans);
|
||||
repaint.request_repaint();
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Err(e) = std::thread::Builder::new().name("lc-rename-preview".into()).spawn(work) {
|
||||
log::warn!("rename preview: {e}");
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, rows.clone())));
|
||||
(read(&rows), total)
|
||||
}
|
||||
|
||||
/// Help ▸ What's New (docs/whats-new.md).
|
||||
pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md");
|
||||
|
||||
@@ -52,7 +99,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
.to_string();
|
||||
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12));
|
||||
let shown = egui::Window::new(title)
|
||||
let shown = egui::Window::new(crate::i18n::tr(&title)).id(egui::Id::new("lightcraft-dialog"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.frame(frame)
|
||||
@@ -67,7 +114,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
match &mut dlg {
|
||||
Dialog::AutoStack { gap } => {
|
||||
ui.label(egui::RichText::new("Stack photos taken within this time of each other:").color(t.text_label));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label));
|
||||
ui.add(
|
||||
egui::Slider::new(gap, 0.0..=86400.0)
|
||||
.logarithmic(true)
|
||||
@@ -83,15 +130,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
Dialog::Cull { reject_below, pick_best } => {
|
||||
let n = app.session.selection.ids.len();
|
||||
let scope = if n > 1 { format!("the {n} selected photos") } else { format!("the {} photos in view", app.session.visible_cloned().len()) };
|
||||
let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) };
|
||||
ui.label(egui::RichText::new(format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).")).color(t.text_label));
|
||||
ui.add_space(6.0);
|
||||
let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text("Reject below focus").step_by(1.0));
|
||||
crate::widgets::register(ui.ctx(), "field:cullReject", r.rect);
|
||||
ui.label(egui::RichText::new(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." }).color(t.text_dim));
|
||||
let r = ui.checkbox(pick_best, "Pick the sharpest photo of each burst");
|
||||
let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst"));
|
||||
crate::widgets::register(ui.ctx(), "check:cullPick", r.rect);
|
||||
ui.label(egui::RichText::new("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.").color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim));
|
||||
}
|
||||
Dialog::WhatsNew => {
|
||||
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
@@ -120,14 +167,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if ui.button("Copy to Clipboard").clicked() {
|
||||
if ui.button(crate::i18n::tr("Copy to Clipboard")).clicked() {
|
||||
let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect();
|
||||
ui.ctx().copy_text(text);
|
||||
}
|
||||
}
|
||||
Dialog::AllMetadata { title, rows, search } => {
|
||||
ui.label(egui::RichText::new(title.as_str()).color(t.text_label));
|
||||
let r = ui.add(egui::TextEdit::singleline(search).hint_text("Filter fields").desired_width(f32::INFINITY));
|
||||
let r = ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:metadataSearch", r.rect);
|
||||
let q = search.trim().to_lowercase();
|
||||
let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q);
|
||||
@@ -170,7 +217,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
Dialog::SmartRules { name, rules, .. } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:smartName", r.rect);
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
@@ -183,22 +230,22 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.label(
|
||||
egui::RichText::new(match problems.first() {
|
||||
Some(p) => p.clone(),
|
||||
None => format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }),
|
||||
None => crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n),
|
||||
})
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::NewSmartAlbum { name } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY));
|
||||
r.request_focus();
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
confirm = true;
|
||||
}
|
||||
let rules = app.session.view_rules();
|
||||
let n = app.session.catalog.query(&rules, &Default::default()).len();
|
||||
ui.label(egui::RichText::new(format!("Matches: {}", rules.describe())).color(t.text_label));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", rules.describe())).color(t.text_label));
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }))
|
||||
egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
@@ -252,7 +299,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
);
|
||||
}
|
||||
if n > 1 {
|
||||
ui.label(egui::RichText::new(format!("All {n} selected photos move by the same amount.")).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
Dialog::LabelNames { names, save_as } => {
|
||||
@@ -278,17 +325,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
field(ui, "Save as set", |ui| {
|
||||
let te = ui.add(egui::TextEdit::singleline(save_as).hint_text("Optional name").desired_width(f32::INFINITY));
|
||||
let te = ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:labelSetName", te.rect);
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.")
|
||||
egui::RichText::new(crate::i18n::tr("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name."))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::Rename { template, start } => {
|
||||
let n = app.session.targets(&json!({})).len();
|
||||
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim));
|
||||
let template_id = egui::Id::new("rename-template");
|
||||
let tags_open = field(ui, "Template", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
@@ -313,26 +360,33 @@ 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();
|
||||
egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| {
|
||||
for pl in preview.as_array().into_iter().flatten().take(6) {
|
||||
ui.label(egui::RichText::new(pl["from"].as_str().unwrap_or("")).color(t.text_dim));
|
||||
ui.label(egui::RichText::new("→").color(t.text_dim));
|
||||
ui.label(egui::RichText::new(pl["to"].as_str().unwrap_or("")).color(t.text));
|
||||
ui.end_row();
|
||||
let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize);
|
||||
match &preview {
|
||||
Some(rows) => {
|
||||
egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| {
|
||||
for pl in rows {
|
||||
ui.label(egui::RichText::new(&pl.from).color(t.text_dim));
|
||||
ui.label(egui::RichText::new("→").color(t.text_dim));
|
||||
ui.label(egui::RichText::new(&pl.to).color(t.text));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let more = preview.as_array().map_or(0, Vec::len).saturating_sub(6);
|
||||
None => {
|
||||
ui.label(egui::RichText::new("Checking names…").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
let more = total.saturating_sub(RENAME_PREVIEW_ROWS);
|
||||
if more > 0 {
|
||||
ui.label(egui::RichText::new(format!("… and {more} more")).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
Dialog::RenameKeyword { from, to } => {
|
||||
let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count();
|
||||
let r = ui.add(egui::TextEdit::singleline(to).hint_text("New name").desired_width(f32::INFINITY));
|
||||
let r = ui.add(egui::TextEdit::singleline(to).hint_text(crate::i18n::tr("New name")).desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:keywordName", r.rect);
|
||||
r.request_focus();
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
@@ -348,7 +402,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
Dialog::MergeKeywords { from, into } => {
|
||||
ui.label(egui::RichText::new(format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::<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 +431,23 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY));
|
||||
r.request_focus();
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
confirm = true;
|
||||
}
|
||||
}
|
||||
Dialog::CreatePreset { name, group, groups } => {
|
||||
ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY));
|
||||
ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY));
|
||||
ui.label(egui::RichText::new("Settings to include").color(t.text_dim));
|
||||
ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY));
|
||||
ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim));
|
||||
group_checklist(ui, "presetInclude", groups);
|
||||
}
|
||||
Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups),
|
||||
Dialog::PasteSettings { groups } => {
|
||||
let n = app.session.targets(&json!({})).len();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }))
|
||||
egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
group_checklist(ui, "pasteGroup", groups);
|
||||
@@ -401,11 +455,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => {
|
||||
use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S};
|
||||
let n = app.session.selection.ids.len().max(1);
|
||||
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim));
|
||||
// Preset: load a built-in or saved set of options into the dialog
|
||||
field(ui, "Preset", |ui| {
|
||||
let mut chosen = None;
|
||||
let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text("Choose…").show_ui(ui, |ui| {
|
||||
let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| {
|
||||
let mut builtin = true;
|
||||
for (p, b) in app.session.all_export_presets() {
|
||||
if builtin && !b {
|
||||
@@ -434,7 +488,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui,
|
||||
"Format",
|
||||
"exportFormat",
|
||||
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original")],
|
||||
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")],
|
||||
&mut opts.format,
|
||||
);
|
||||
if !opts.format.is_rendered() {
|
||||
@@ -458,7 +512,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
if !rendered {
|
||||
} else if opts.format == F::Avif {
|
||||
ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim));
|
||||
} else {
|
||||
use lightcraft_engine::export::OutputSpace as C;
|
||||
choices(
|
||||
@@ -517,10 +571,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
&mut opts.metadata,
|
||||
);
|
||||
if !matches!(opts.metadata, M::None | M::Copyright) {
|
||||
ui.checkbox(&mut opts.remove_location, "Remove location info");
|
||||
ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info"));
|
||||
}
|
||||
let mut wm_on = opts.watermark.is_some();
|
||||
if ui.checkbox(&mut wm_on, "Watermark").changed() {
|
||||
if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() {
|
||||
opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() });
|
||||
}
|
||||
if let Some(wm) = &mut opts.watermark {
|
||||
@@ -538,7 +592,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
field(ui, "Graphic", |ui| {
|
||||
trailing_button_row(ui, |ui| {
|
||||
if app.services.pick_files.is_some()
|
||||
&& crate::widgets::text_button(ui, "exportWmChoose", "Choose…", false).clicked()
|
||||
&& crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked()
|
||||
&& let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next())
|
||||
{
|
||||
wm.image = f;
|
||||
@@ -551,7 +605,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
wm.image_width = (width / 100.0) as f32;
|
||||
}
|
||||
} else {
|
||||
choices(ui, app.ui.language.tr("Orientation"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal")), (true, app.ui.language.tr("Vertical"))], &mut wm.vertical);
|
||||
choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical);
|
||||
field(ui, "Text", |ui| {
|
||||
ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text("© Your Name").desired_width(f32::INFINITY))
|
||||
});
|
||||
@@ -572,7 +626,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
wm.opacity = (op / 100.0) as f32;
|
||||
}
|
||||
if !graphic {
|
||||
ui.checkbox(&mut wm.shadow, "Shadow");
|
||||
ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -605,7 +659,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
field(ui, "Folder", |ui| {
|
||||
trailing_button_row(ui, |ui| {
|
||||
if app.services.pick_folder.is_some()
|
||||
&& crate::widgets::text_button(ui, "exportChooseFolder", "Choose…", false).clicked()
|
||||
&& crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked()
|
||||
&& let Some(pick) = app.services.pick_folder.as_mut()
|
||||
&& let Some(d) = pick()
|
||||
{
|
||||
@@ -615,7 +669,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
});
|
||||
field(ui, "Subfolder", |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text("none").desired_width(f32::INFINITY))
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY))
|
||||
});
|
||||
use lightcraft_engine::export::Conflict as K;
|
||||
choices(
|
||||
@@ -629,17 +683,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
field(ui, "Save preset", |ui| {
|
||||
trailing_button_row(ui, |ui| {
|
||||
let named = !preset_name.trim().is_empty();
|
||||
if crate::widgets::text_button(ui, "exportSavePreset", "Save", false).clicked() && named {
|
||||
if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named {
|
||||
let params = export_dialog_params(opts, *full_size, resize, *limit_kb);
|
||||
match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) {
|
||||
Ok(_) => {
|
||||
app.toast(ui.ctx(), format!("Saved export preset “{}”", preset_name.trim()));
|
||||
app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim()));
|
||||
preset_name.clear();
|
||||
}
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
ui.add(egui::TextEdit::singleline(preset_name).hint_text("Preset name").desired_width(ui.available_width()));
|
||||
ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -647,14 +701,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::Import { opts } => crate::import::body(app, ui, opts),
|
||||
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
|
||||
Dialog::ConfirmDelete { count } => {
|
||||
let what = if *count == 1 { "this photo".to_string() } else { format!("these {count} photos") };
|
||||
ui.label(format!("Move {what} to Recently Deleted?"));
|
||||
ui.label(egui::RichText::new("They can be restored from Recently Deleted until it is emptied.").color(t.text_dim));
|
||||
let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
|
||||
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
|
||||
}
|
||||
Dialog::About => {
|
||||
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
|
||||
ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("MIT OR Apache-2.0. Font: Inter (OFL). Icons: original.");
|
||||
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("MIT OR Apache-2.0. Fonts: Inter / BIZ UDPGothic (OFL). Icons: original.");
|
||||
ui.add_space(10.0);
|
||||
let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE))
|
||||
.fill(t.accent)
|
||||
@@ -681,7 +735,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
|
||||
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
|
||||
if let Some(sc) = sc {
|
||||
ui.label(*label);
|
||||
ui.label(crate::i18n::tr(label));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(*id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
@@ -689,7 +743,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if let Some(sc) = c.shortcut {
|
||||
ui.label(c.label);
|
||||
ui.label(crate::i18n::tr(c.label));
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new(c.id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
@@ -702,7 +756,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
.map(|c| c.1)
|
||||
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))
|
||||
.unwrap_or(id);
|
||||
ui.label(label);
|
||||
ui.label(crate::i18n::tr(label));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(*id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
@@ -714,7 +768,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
|
||||
if !informational && ui.button("Cancel").clicked() {
|
||||
if !informational && ui.button(crate::i18n::tr("Cancel")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
let add_label;
|
||||
@@ -730,7 +784,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
_ if informational => "Close",
|
||||
_ => "OK",
|
||||
};
|
||||
if ui.button(ok).clicked() {
|
||||
if ui.button(crate::i18n::tr(ok)).clicked() {
|
||||
if informational {
|
||||
close = true;
|
||||
} else {
|
||||
@@ -929,15 +983,15 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex
|
||||
(R::Megapixels, "Megapixels"),
|
||||
(R::Percent, "Percentage"),
|
||||
];
|
||||
let r_full = ui.checkbox(full, "Full size");
|
||||
let r_full = ui.checkbox(full, crate::i18n::tr("Full size"));
|
||||
crate::widgets::register(ui.ctx(), "check:exportFullSize", r_full.rect);
|
||||
if !*full {
|
||||
field(ui, "Resize to", |ui| {
|
||||
let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1);
|
||||
let before = r.mode;
|
||||
let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(cur).show_ui(ui, |ui| {
|
||||
let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
|
||||
for (m, l) in MODES {
|
||||
ui.selectable_value(&mut r.mode, m, l);
|
||||
ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l));
|
||||
}
|
||||
});
|
||||
crate::widgets::register(ui.ctx(), "combo:exportResizeMode", c.response.rect);
|
||||
@@ -970,7 +1024,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex
|
||||
r.height = h as u32;
|
||||
}
|
||||
}
|
||||
let c = ui.checkbox(&mut r.dont_enlarge, "Don't enlarge");
|
||||
let c = ui.checkbox(&mut r.dont_enlarge, crate::i18n::tr("Don't enlarge"));
|
||||
crate::widgets::register(ui.ctx(), "check:exportDontEnlarge", c.rect);
|
||||
}
|
||||
let mut p = *ppi as f64;
|
||||
@@ -999,7 +1053,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)
|
||||
})
|
||||
|
||||
@@ -76,7 +76,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let d = app.session.develop_of(id).unwrap_or_default();
|
||||
// a raw shown from its embedded JPEG (preview only) gets the rendered-file white balance scale
|
||||
let raw = app.session.catalog.photo(id).is_some_and(|p| p.develops_raw());
|
||||
let raw = app.session.catalog.photo(id).is_some_and(|p| p.develops_raw() && !p.relative_wb());
|
||||
let mut d = d;
|
||||
if d.wb.mode == WbMode::AsShot && app.session.catalog.photo(id).is_some_and(|p| p.relative_wb()) {
|
||||
let wb = &mut std::sync::Arc::make_mut(&mut d).wb;
|
||||
wb.temp = 6500.0;
|
||||
wb.tint = 0.0;
|
||||
}
|
||||
let preview_only = app.session.catalog.photo(id).and_then(|p| p.preview_only.clone());
|
||||
|
||||
if app.ui.histogram {
|
||||
@@ -87,20 +93,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 +125,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 +138,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 +162,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 +174,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 +218,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 +226,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 +238,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 +263,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 +273,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 +284,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 +304,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 +323,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 +331,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 +391,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 +414,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 +424,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 +543,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 +561,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 +571,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 +618,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 +809,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 +829,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 +838,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 +871,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 +940,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 +952,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 +1012,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 +1065,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 +1111,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 +1126,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}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,33 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.button("Rotate Left").clicked() {
|
||||
if ui.button(crate::i18n::tr("Rotate Left")).clicked() {
|
||||
let _ = app.run("photo.rotateLeft", json!({}));
|
||||
}
|
||||
if ui.button("Rotate Right").clicked() {
|
||||
if ui.button(crate::i18n::tr("Rotate Right")).clicked() {
|
||||
let _ = app.run("photo.rotateRight", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
// the original moved or its drive is gone: point the photo at the file again
|
||||
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() {
|
||||
// (a cached answer, checked off the UI thread)
|
||||
let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if app.session.media.availability.is_offline(path));
|
||||
if missing && ui.button(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 +905,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.button("Delete Photo").clicked() {
|
||||
if ui.button(crate::i18n::tr("Delete Photo")).clicked() {
|
||||
let _ = app.run("photo.delete", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 +194,15 @@ 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() {
|
||||
for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] {
|
||||
// joined with the platform's separator, like the paths browsing gives back
|
||||
let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() };
|
||||
if std::path::Path::new(&p).is_dir() {
|
||||
// (checked off the UI thread: a home folder can be on a network share)
|
||||
if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) {
|
||||
builtin.push((name.to_string(), p));
|
||||
}
|
||||
}
|
||||
@@ -231,9 +233,9 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let hidden = app.ui.hidden_locations.len();
|
||||
if hidden > 0 {
|
||||
let label = format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" });
|
||||
let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden);
|
||||
if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0)
|
||||
.on_hover_text("Put the locations you removed from Local back (no files change)")
|
||||
.on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("local.restoreHidden", json!({}));
|
||||
@@ -305,15 +307,13 @@ pub(crate) fn local_places(
|
||||
out
|
||||
}
|
||||
|
||||
/// The subfolders of `path` (not hidden ones), sorted; listed at most every 2 s per folder.
|
||||
/// The subfolders of `path` (not hidden ones), sorted; listed on a worker thread at most every
|
||||
/// 2 s per folder (empty until the first listing).
|
||||
fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
|
||||
let id = egui::Id::new(("subfolders", path.to_string()));
|
||||
let now = ui.input(|i| i.time);
|
||||
if let Some((t, v)) = ui.data(|d| d.get_temp::<(f64, Vec<(String, String)>)>(id))
|
||||
&& now - t < 2.0
|
||||
{
|
||||
return v;
|
||||
}
|
||||
fs_cached(ui, "subfolders", path, 2.0, list_subfolders).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn list_subfolders(path: &str) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = std::fs::read_dir(path)
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
@@ -326,10 +326,59 @@ fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
v.sort_by_key(|(n, _)| n.to_lowercase());
|
||||
ui.data_mut(|d| d.insert_temp(id, (now, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker
|
||||
/// thread at most every `every` seconds: a folder on a sleeping NAS, a dropped share or a
|
||||
/// spinning-up drive never blocks a frame. `None` until the first answer arrives.
|
||||
pub(crate) fn fs_cached<T: Clone + Send + 'static>(ui: &egui::Ui, kind: &'static str, path: &str, every: f64, f: fn(&str) -> T) -> Option<T> {
|
||||
struct Entry<T> {
|
||||
value: Option<T>,
|
||||
at: Option<f64>,
|
||||
running: bool,
|
||||
}
|
||||
type Cell<T> = std::sync::Arc<std::sync::Mutex<Entry<T>>>;
|
||||
let id = egui::Id::new(("fs-cached", kind, path.to_string()));
|
||||
let now = ui.input(|i| i.time);
|
||||
let cell: Cell<T> = match ui.data(|d| d.get_temp::<Cell<T>>(id)) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let c: Cell<T> = std::sync::Arc::new(std::sync::Mutex::new(Entry { value: None, at: None, running: false }));
|
||||
ui.data_mut(|d| d.insert_temp(id, c.clone()));
|
||||
c
|
||||
}
|
||||
};
|
||||
let lock = |c: &Cell<T>| c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).value.clone();
|
||||
let start = {
|
||||
let mut e = cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let due = !e.running && e.at.is_none_or(|at| now - at >= every || now < at);
|
||||
if due {
|
||||
e.running = true;
|
||||
e.at = Some(now);
|
||||
}
|
||||
due
|
||||
};
|
||||
if start {
|
||||
let (out, path, repaint) = (cell.clone(), path.to_string(), ui.ctx().clone());
|
||||
let work = move || {
|
||||
let v = f(&path);
|
||||
let mut e = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
e.value = Some(v);
|
||||
e.running = false;
|
||||
drop(e);
|
||||
repaint.request_repaint();
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::thread::Builder::new().name("lc-fs-list".into()).spawn(work).is_err() {
|
||||
cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner).running = false;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
}
|
||||
lock(&cell)
|
||||
}
|
||||
|
||||
/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its
|
||||
/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the
|
||||
/// way down to it open whenever the browsed folder changes, so it shows highlighted in place.
|
||||
@@ -384,20 +433,26 @@ fn folder_tree(
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if (transient || indent > 0.0)
|
||||
&& ui.button(if transient { "Keep in Local" } else { "Add to Local" }).on_hover_text("List this folder in Local from now on").clicked()
|
||||
&& ui
|
||||
.button(if transient { "Keep in Local" } else { "Add to Local" })
|
||||
.on_hover_text(crate::i18n::tr("List this folder in Local from now on"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("local.addRoot", json!({"path": path}));
|
||||
ui.close();
|
||||
}
|
||||
if indent == 0.0
|
||||
&& ui.button("Remove from Local").on_hover_text("Hides this shortcut only; the folder and its photos stay as they are").clicked()
|
||||
&& ui
|
||||
.button(crate::i18n::tr("Remove from Local"))
|
||||
.on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("local.hide", json!({"path": path}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Rename Folder…").clicked() {
|
||||
if ui.button(crate::i18n::tr("Rename Folder…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
|
||||
title: format!("Rename “{name}”"),
|
||||
title: crate::i18n::tr_format!("Rename “{name}”", name = name),
|
||||
hint: "Folder name (renamed on disk; its photos follow)".into(),
|
||||
value: name.to_string(),
|
||||
command: "folder.rename".into(),
|
||||
@@ -405,7 +460,7 @@ fn folder_tree(
|
||||
key: "name".into(),
|
||||
});
|
||||
}
|
||||
if app.services.pick_folder.is_some() && ui.button("Move Folder To…").clicked() {
|
||||
if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() {
|
||||
let into = app.services.pick_folder.as_mut().and_then(|f| f());
|
||||
if let Some(into) = into {
|
||||
match app.run("folder.move", json!({"path": path, "into": into})) {
|
||||
@@ -415,7 +470,7 @@ fn folder_tree(
|
||||
}
|
||||
}
|
||||
if app.services.reveal.is_some()
|
||||
&& ui.button("Show in Finder").clicked()
|
||||
&& ui.button(crate::i18n::tr("Show in Finder")).clicked()
|
||||
&& let Some(f) = app.services.reveal.as_mut()
|
||||
{
|
||||
let _ = f(path);
|
||||
@@ -489,7 +544,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
|
||||
drop_target(app, ui, &resp, a);
|
||||
}
|
||||
if let Some(rules) = &a.smart {
|
||||
resp = resp.on_hover_text(format!("Smart album: {}", rules.describe()));
|
||||
resp = resp.on_hover_text(crate::i18n::tr_format!("Smart album: {}", rules.describe()));
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
|
||||
@@ -512,7 +567,7 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
let n = ids.len();
|
||||
match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) {
|
||||
Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name)),
|
||||
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n)),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
app.ui.dragging_photos = None;
|
||||
@@ -521,27 +576,27 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
|
||||
|
||||
fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
|
||||
resp.context_menu(|ui| {
|
||||
if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() {
|
||||
if !a.folder && !a.is_smart() && ui.button(crate::i18n::tr("Add Selected Photos")).clicked() {
|
||||
let _ = app.run("album.addPhotos", json!({"id": a.id.0}));
|
||||
}
|
||||
if !a.folder && !a.is_smart() {
|
||||
let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick);
|
||||
if !is_target && ui.button("Set as Target Album (B adds to it)").clicked() {
|
||||
if !is_target && ui.button(crate::i18n::tr("Set as Target Album (B adds to it)")).clicked() {
|
||||
let _ = app.run("album.setTarget", json!({"id": if a.quick { serde_json::Value::Null } else { json!(a.id.0) }}));
|
||||
}
|
||||
if is_target && !a.quick && ui.button("Stop Using as Target Album").clicked() {
|
||||
if is_target && !a.quick && ui.button(crate::i18n::tr("Stop Using as Target Album")).clicked() {
|
||||
let _ = app.run("album.setTarget", json!({"id": null}));
|
||||
}
|
||||
}
|
||||
if a.quick && ui.button("Clear Quick Collection").clicked() {
|
||||
if a.quick && ui.button(crate::i18n::tr("Clear Quick Collection")).clicked() {
|
||||
let _ = app.run("album.clearQuick", json!({}));
|
||||
}
|
||||
if a.is_smart() && ui.button("Edit Smart Album…").clicked() {
|
||||
if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() {
|
||||
// older smart albums keep their filter fields; the editor works on the rule set
|
||||
let rules = a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default();
|
||||
app.ui.dialog = Some(crate::state::Dialog::SmartRules { id: Some(a.id.0), name: a.name.clone(), rules });
|
||||
}
|
||||
if a.is_smart() && ui.button("Update Rules from Current Filter").clicked() {
|
||||
if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() {
|
||||
let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true}));
|
||||
}
|
||||
if !a.folder {
|
||||
@@ -551,12 +606,12 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
|
||||
let _ = app.run("library.selectAll", json!({}));
|
||||
};
|
||||
let has_photos = app.session.catalog.album_count(a.id) > 0;
|
||||
if ui.add_enabled(has_photos, egui::Button::new("Export Album…")).clicked() {
|
||||
if ui.add_enabled(has_photos, egui::Button::new(crate::i18n::tr("Export Album…"))).clicked() {
|
||||
show_all(app);
|
||||
let _ = app.run("dialog.export", json!({}));
|
||||
}
|
||||
ui.add_enabled_ui(has_photos, |ui| {
|
||||
ui.menu_button("Export Album with Preset", |ui| {
|
||||
ui.menu_button(crate::i18n::tr("Export Album with Preset"), |ui| {
|
||||
for (p, _) in app.session.all_export_presets() {
|
||||
if ui.button(&p.name).clicked() {
|
||||
show_all(app);
|
||||
@@ -573,8 +628,8 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
|
||||
let mut folders: Vec<(u64, String)> =
|
||||
app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect();
|
||||
folders.sort_by_key(|(_, n)| n.to_lowercase());
|
||||
ui.menu_button("Move to", |ui| {
|
||||
if ui.add_enabled(a.parent.is_some(), egui::Button::new("Top Level")).clicked() {
|
||||
ui.menu_button(crate::i18n::tr("Move to"), |ui| {
|
||||
if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() {
|
||||
let _ = app.run("album.move", json!({"id": a.id.0, "parent": null}));
|
||||
}
|
||||
for (fid, name) in &folders {
|
||||
@@ -583,10 +638,10 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.button("Rename…").clicked() {
|
||||
if ui.button(crate::i18n::tr("Rename…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() });
|
||||
}
|
||||
if ui.button("Delete").clicked() {
|
||||
if ui.button(crate::i18n::tr("Delete")).clicked() {
|
||||
let _ = app.run("album.delete", json!({"id": a.id.0}));
|
||||
}
|
||||
});
|
||||
@@ -620,7 +675,7 @@ fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let (kr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
|
||||
ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, "Keywords", t.semibold(13.5), t.text_label);
|
||||
ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Keywords"), t.semibold(13.5), t.text_label);
|
||||
keyword_rows(app, ui, &tree, 0.0);
|
||||
}
|
||||
|
||||
@@ -656,20 +711,20 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let has_sel = app.session.active().is_some();
|
||||
if ui.add_enabled(has_sel, egui::Button::new("Add to Selected Photos")).clicked() {
|
||||
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]}));
|
||||
}
|
||||
if ui.add_enabled(has_sel, egui::Button::new("Remove from Selected Photos")).clicked() {
|
||||
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Rename Keyword…").clicked() {
|
||||
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() });
|
||||
}
|
||||
if ui.button("Merge into…").clicked() {
|
||||
if ui.button(crate::i18n::tr("Merge into…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() });
|
||||
}
|
||||
if ui.button("Delete Keyword").clicked() {
|
||||
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": n.path}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! The library couldn't be opened at launch (issue #100): never a silent in-memory session. A
|
||||
//! blocking window says which library and why, and offers Try Again, Choose Another Library…,
|
||||
//! Continue Without Saving and Quit. Continuing shows a banner under the top bar for as long as the
|
||||
//! session is temporary.
|
||||
|
||||
use egui::{Align2, RichText, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// A library that failed to open (set by the host at launch).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LibraryProblem {
|
||||
/// The library folder ("" when there was none to try, e.g. no home folder).
|
||||
pub path: String,
|
||||
/// Why it didn't open.
|
||||
pub error: String,
|
||||
/// Continue Without Saving was chosen: the banner shows instead of the window.
|
||||
pub dismissed: bool,
|
||||
/// Files from the command line, imported once a library opens.
|
||||
pub pending_import: Vec<String>,
|
||||
/// Try Again reopens `path` as a library folder (`false` in the browser, whose storage isn't
|
||||
/// a folder: reloading the page tries again).
|
||||
pub can_retry: bool,
|
||||
}
|
||||
|
||||
impl LibraryProblem {
|
||||
pub fn new(path: impl Into<String>, error: impl Into<String>) -> LibraryProblem {
|
||||
LibraryProblem { path: path.into(), error: error.into(), can_retry: true, ..Default::default() }
|
||||
}
|
||||
|
||||
/// `ui.inspect` → `libraryProblem`.
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
json!({"path": self.path, "error": self.error, "temporarySession": self.dismissed, "pendingImport": self.pending_import.len()})
|
||||
}
|
||||
}
|
||||
|
||||
/// What the user chose.
|
||||
enum Choice {
|
||||
Retry,
|
||||
Choose,
|
||||
Temporary,
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Open `path` (`None`: ask for a folder) through `app.openLibrary`; on success the problem is
|
||||
/// over (and command-line files are imported), else it shows the new error.
|
||||
fn open(app: &mut LightcraftApp, path: Option<String>) {
|
||||
let params = match &path {
|
||||
Some(p) => json!({"path": p}),
|
||||
None => json!({}),
|
||||
};
|
||||
match app.run("app.openLibrary", params) {
|
||||
Ok(serde_json::Value::Null) => {} // folder dialog cancelled
|
||||
Ok(_) => {
|
||||
let files = app.library_problem.take().map(|p| p.pending_import).unwrap_or_default();
|
||||
if !files.is_empty() {
|
||||
let _ = app.run("library.import", json!({"paths": files}));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(p) = app.library_problem.as_mut() {
|
||||
if let Some(path) = path {
|
||||
p.path = path;
|
||||
}
|
||||
p.error = e;
|
||||
p.dismissed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the problem once a library is open (e.g. through Settings → Open Library…).
|
||||
pub fn logic(app: &mut LightcraftApp) {
|
||||
if app.library_problem.is_some() && app.session.library.is_some() {
|
||||
app.library_problem = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking window (until a choice is made).
|
||||
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(problem) = app.library_problem.clone() else { return };
|
||||
if problem.dismissed {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| {
|
||||
// swallows clicks: nothing behind the window can be used meanwhile
|
||||
ui.allocate_rect(screen, egui::Sense::click_and_drag());
|
||||
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160));
|
||||
});
|
||||
let mut choice = None;
|
||||
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
|
||||
egui::Window::new(crate::i18n::tr("Your library couldn't be opened"))
|
||||
.id(egui::Id::new("library-problem"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.frame(frame)
|
||||
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.default_width(460.0)
|
||||
.show(ctx, |ui| {
|
||||
// a fixed width: a wrapped label in an auto-sized, centred window would move it every frame
|
||||
ui.set_width(440.0);
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
if problem.path.is_empty() {
|
||||
ui.label(RichText::new(crate::i18n::tr("LightCraft couldn't find where to keep your library.")).color(t.text));
|
||||
} else {
|
||||
ui.label(RichText::new(crate::i18n::tr("LightCraft couldn't open the library at")).color(t.text_label));
|
||||
ui.label(RichText::new(&problem.path).font(t.semibold(12.5)).color(t.text));
|
||||
}
|
||||
ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap());
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \
|
||||
or reconnecting its drive), open a different library or create a new one, or continue without a library — \
|
||||
then nothing you do is saved.",
|
||||
)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| {
|
||||
let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0)));
|
||||
register(ui.ctx(), format!("button:{id}"), r.rect);
|
||||
if r.clicked() {
|
||||
choice = Some(c);
|
||||
}
|
||||
};
|
||||
button(ui, "libraryRetry", "Try Again", problem.can_retry && !problem.path.is_empty(), Choice::Retry);
|
||||
button(ui, "libraryChoose", "Choose Another Library…", app.services.pick_folder.is_some(), Choice::Choose);
|
||||
button(ui, "libraryTemporary", "Continue Without Saving", true, Choice::Temporary);
|
||||
button(ui, "libraryQuit", "Quit", true, Choice::Quit);
|
||||
});
|
||||
});
|
||||
match choice {
|
||||
Some(Choice::Retry) => open(app, Some(problem.path)),
|
||||
Some(Choice::Choose) => open(app, None),
|
||||
Some(Choice::Temporary) => {
|
||||
if let Some(p) = app.library_problem.as_mut() {
|
||||
p.dismissed = true;
|
||||
}
|
||||
let files = app.library_problem.as_mut().map(|p| std::mem::take(&mut p.pending_import)).unwrap_or_default();
|
||||
if !files.is_empty() {
|
||||
let _ = app.run("library.import", json!({"paths": files}));
|
||||
}
|
||||
}
|
||||
Some(Choice::Quit) => app.ui.quit = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The banner under the top bar while the session is temporary (Continue Without Saving).
|
||||
pub fn banner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if !app.library_problem.as_ref().is_some_and(|p| p.dismissed) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut reopen = false;
|
||||
egui::Panel::top("library-problem-banner")
|
||||
.exact_size(30.0)
|
||||
.frame(egui::Frame::NONE.fill(t.caution).inner_margin(egui::Margin::symmetric(12, 0)))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
let r = ui.label(
|
||||
RichText::new(crate::i18n::tr("Temporary session — your library isn't open, and nothing you do here is saved."))
|
||||
.font(t.semibold(12.5))
|
||||
.color(t.canvas),
|
||||
);
|
||||
register(ui.ctx(), "indicator:temporarySession", r.rect);
|
||||
let b = ui.add(
|
||||
egui::Button::new(RichText::new(crate::i18n::tr("Open Library…")).color(t.canvas)).fill(egui::Color32::from_black_alpha(40)),
|
||||
);
|
||||
register(ui.ctx(), "button:libraryReopen", b.rect);
|
||||
reopen = b.clicked();
|
||||
});
|
||||
});
|
||||
if reopen && let Some(p) = app.library_problem.as_mut() {
|
||||
p.dismissed = false;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,7 +10,9 @@ pub mod edit;
|
||||
pub mod filterbar;
|
||||
pub mod grid;
|
||||
pub mod left;
|
||||
pub mod library_problem;
|
||||
pub mod masking;
|
||||
pub mod notices;
|
||||
pub mod presets;
|
||||
pub mod profiles;
|
||||
pub mod right;
|
||||
@@ -69,8 +71,10 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32;
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast")));
|
||||
let galley = painter.layout_no_wrap(text, t.font(14.0), t.text.gamma_multiply(fade));
|
||||
let size = vec2(galley.size().x + 40.0, 40.0);
|
||||
// long messages (a save warning, the web build's notices) wrap within the canvas
|
||||
let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0);
|
||||
let galley = painter.layout(text, t.font(14.0), t.text.gamma_multiply(fade), wrap);
|
||||
let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0));
|
||||
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size);
|
||||
painter.rect_filled(r, 6.0, egui::Color32::from_black_alpha((200.0 * fade) as u8));
|
||||
painter.galley(r.center() - galley.size() / 2.0, galley, t.text);
|
||||
@@ -81,6 +85,6 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, title, t.semibold(18.0), t.text_label);
|
||||
p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, body, t.font(13.0), t.text_dim);
|
||||
p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, crate::i18n::tr(title), t.semibold(18.0), t.text_label);
|
||||
p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, crate::i18n::tr(body), t.font(13.0), t.text_dim);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Things the user must not miss (issue #103): settings files that were damaged or unreadable when
|
||||
//! the library opened (and host warnings such as a damaged `ui.json`), and quitting while changes
|
||||
//! are only in memory.
|
||||
|
||||
use egui::{Align2, RichText, vec2};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Collect the library's settings-file warnings (each is shown once).
|
||||
pub fn logic(app: &mut LightcraftApp) {
|
||||
let new = app.session.take_library_warnings();
|
||||
app.notices.extend(new);
|
||||
}
|
||||
|
||||
/// The window may close now (`true`), or the quit prompt is shown (`false`): changes that
|
||||
/// couldn't be written are retried first — the log, then a snapshot of everything in memory.
|
||||
pub fn may_close(app: &mut LightcraftApp) -> bool {
|
||||
if app.quit_confirmed || app.session.unsaved().is_none() {
|
||||
return true;
|
||||
}
|
||||
let _ = app.session.persist();
|
||||
if app.session.unsaved().is_some()
|
||||
&& let Err(e) = app.session.close_library()
|
||||
{
|
||||
log::error!("quit: library not saved: {e}");
|
||||
}
|
||||
match app.session.unsaved() {
|
||||
None => true,
|
||||
Some((n, e)) => {
|
||||
app.quit_prompt = Some(format!(
|
||||
"{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
|
||||
if n == 1 { "1 change".to_string() } else { format!("{n} changes") },
|
||||
if n == 1 { "it is" } else { "they are" }
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Choice {
|
||||
Retry,
|
||||
QuitAnyway,
|
||||
Cancel,
|
||||
Ok,
|
||||
}
|
||||
|
||||
fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&str, &str, Choice)]) -> Option<usize> {
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
egui::Area::new(egui::Id::new((id, "dim"))).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
|
||||
ui.allocate_rect(screen, egui::Sense::click_and_drag());
|
||||
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
|
||||
});
|
||||
let mut chosen = None;
|
||||
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
|
||||
egui::Window::new(title)
|
||||
.id(egui::Id::new(id))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.frame(frame)
|
||||
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.default_width(440.0)
|
||||
.show(ctx, |ui| {
|
||||
// a fixed width: a wrapped label in an auto-sized, centred window would move it every frame
|
||||
ui.set_width(420.0);
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
ui.add(egui::Label::new(RichText::new(text).color(t.text_label)).wrap());
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, (bid, label, _)) in buttons.iter().enumerate() {
|
||||
let r = ui.add(egui::Button::new(*label).min_size(vec2(0.0, 26.0)));
|
||||
register(ui.ctx(), format!("button:{bid}"), r.rect);
|
||||
if r.clicked() {
|
||||
chosen = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
chosen
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if let Some(text) = app.quit_prompt.clone() {
|
||||
let buttons = [
|
||||
("quitRetry", "Try Saving Again", Choice::Retry),
|
||||
("quitAnyway", "Quit Anyway", Choice::QuitAnyway),
|
||||
("quitCancel", "Cancel", Choice::Cancel),
|
||||
];
|
||||
let Some(i) = window(ctx, "quit-unsaved", "Quit with unsaved changes?", &text, &buttons) else { return };
|
||||
match buttons[i].2 {
|
||||
Choice::Retry => {
|
||||
app.quit_prompt = None;
|
||||
if may_close(app) {
|
||||
app.ui.quit = true;
|
||||
}
|
||||
}
|
||||
Choice::QuitAnyway => {
|
||||
app.quit_prompt = None;
|
||||
app.quit_confirmed = true;
|
||||
app.ui.quit = true;
|
||||
}
|
||||
Choice::Cancel | Choice::Ok => app.quit_prompt = None,
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(text) = app.notices.first().cloned() {
|
||||
let buttons = [("noticeOk", "OK", Choice::Ok)];
|
||||
if window(ctx, "settings-notice", "Settings file problem", &text, &buttons).is_some() {
|
||||
app.notices.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
@@ -535,9 +553,16 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
ui.label(egui::RichText::new(line).color(t.text_dim));
|
||||
}
|
||||
// offline originals / smart previews
|
||||
// (cached answers, checked off the UI thread: unknown counts as online / no smart preview)
|
||||
if let lightcraft_catalog::Source::File { path } = &p.source {
|
||||
let online = std::path::Path::new(path).exists();
|
||||
let smart = lightcraft_engine::cmd::previews::has_smart_preview(&app.session, p.id);
|
||||
let avail = &app.session.media.availability;
|
||||
let online = !avail.is_offline(path);
|
||||
let smart = app
|
||||
.session
|
||||
.media
|
||||
.smart_dir
|
||||
.as_ref()
|
||||
.is_some_and(|d| avail.exists(&d.join(lightcraft_engine::smart::file_name(&p)).to_string_lossy()) == Some(true));
|
||||
if !online || smart {
|
||||
let text = match (online, smart) {
|
||||
(false, true) => "Original offline · editing the smart preview",
|
||||
@@ -549,7 +574,10 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if text_button(ui, "allMetadata", "All Metadata…", false).on_hover_text("Every EXIF, GPS and XMP field in the file").clicked() {
|
||||
if text_button(ui, "allMetadata", crate::i18n::tr("All Metadata…"), false)
|
||||
.on_hover_text(crate::i18n::tr("Every EXIF, GPS and XMP field in the file"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("dialog.allMetadata", json!({}));
|
||||
}
|
||||
});
|
||||
@@ -609,10 +637,10 @@ fn human_size(bytes: u64) -> String {
|
||||
/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`).
|
||||
fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new("Copyright Status").size(11.5).color(t.text_dim));
|
||||
let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(current.label()).show_ui(ui, |ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim));
|
||||
let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| {
|
||||
for st in lightcraft_catalog::CopyrightStatus::ALL {
|
||||
if ui.selectable_label(st == current, st.label()).clicked() && st != current {
|
||||
if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current {
|
||||
let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()}));
|
||||
}
|
||||
}
|
||||
@@ -647,7 +675,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
padded(ui, |ui| {
|
||||
let kid = egui::Id::new("kw-input");
|
||||
let mut text = ui.data_mut(|d| d.get_temp::<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 +693,17 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
r.context_menu(|ui| {
|
||||
if ui.button("Remove from Photo").clicked() {
|
||||
if ui.button(crate::i18n::tr("Remove from Photo")).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
if ui.button("Show Photos with Keyword").clicked() {
|
||||
if ui.button(crate::i18n::tr("Show Photos with Keyword")).clicked() {
|
||||
let _ = app.run("library.filter", json!({"keyword": k}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Rename Keyword…").clicked() {
|
||||
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() });
|
||||
}
|
||||
if ui.button("Delete Keyword").clicked() {
|
||||
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": k}));
|
||||
}
|
||||
});
|
||||
@@ -689,12 +717,15 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let pid = egui::Id::new("kw-painter");
|
||||
let painting = app.ui.keyword_painter.clone();
|
||||
let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default());
|
||||
let r = ui.add_enabled(painting.is_none(), egui::TextEdit::singleline(&mut k).hint_text("Keyword to paint").desired_width(130.0));
|
||||
let r = ui.add_enabled(
|
||||
painting.is_none(),
|
||||
egui::TextEdit::singleline(&mut k).hint_text(crate::i18n::tr("Keyword to paint")).desired_width(130.0),
|
||||
);
|
||||
register(ui.ctx(), "field:keywordPainter", r.rect);
|
||||
ui.data_mut(|d| d.insert_temp(pid, k.clone()));
|
||||
let label = if painting.is_some() { "Stop" } else { "Paint" };
|
||||
if text_button(ui, "keywordPaint", label, painting.is_some())
|
||||
.on_hover_text("Click photos in the grid to toggle the keyword; Esc stops")
|
||||
.on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }}));
|
||||
@@ -707,7 +738,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let last = typed.rsplit(',').next().unwrap_or("").trim().to_string();
|
||||
let suggestions = (*app.caches.suggestions(&app.session.catalog, &p.meta.keywords, &last, 12)).clone();
|
||||
if !suggestions.is_empty() {
|
||||
ui.label(egui::RichText::new("Suggestions").color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for k in suggestions {
|
||||
let r = ui
|
||||
@@ -732,7 +763,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) {
|
||||
let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session);
|
||||
let current = sets["current"].as_str().unwrap_or_default().to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Keyword Set").color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim));
|
||||
egui::ComboBox::from_id_salt("kw-set").selected_text(¤t).show_ui(ui, |ui| {
|
||||
for set in sets["sets"].as_array().into_iter().flatten() {
|
||||
let name = set["name"].as_str().unwrap_or_default();
|
||||
@@ -741,7 +772,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) {
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Save Current Keywords as Set…").clicked() {
|
||||
if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
|
||||
title: "Save Keyword Set".into(),
|
||||
hint: "Set name".into(),
|
||||
@@ -751,14 +782,16 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) {
|
||||
key: "name".into(),
|
||||
});
|
||||
}
|
||||
if current != lightcraft_engine::cmd::keywords::RECENT && ui.button(format!("Delete “{current}”")).clicked() {
|
||||
if current != lightcraft_engine::cmd::keywords::RECENT
|
||||
&& ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked()
|
||||
{
|
||||
let _ = app.run("keyword.deleteSet", json!({"name": current}));
|
||||
}
|
||||
});
|
||||
});
|
||||
let kws: Vec<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 +821,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
header(ui, "Versions");
|
||||
let t = Tokens::get(ui.ctx());
|
||||
padded(ui, |ui| {
|
||||
if text_button(ui, "versionCreate", "Create Version", false).clicked() {
|
||||
if text_button(ui, "versionCreate", crate::i18n::tr("Create Version"), false).clicked() {
|
||||
let _ = app.run("version.create", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
@@ -797,7 +830,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false);
|
||||
let named_n = p.versions.iter().filter(|v| !v.auto).count();
|
||||
let items = [("Named", "named"), ("Auto", "auto")];
|
||||
let labels = [format!("Named ({named_n})"), format!("Auto ({})", p.versions.len() - named_n)];
|
||||
let labels = [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), format!("Auto ({})", p.versions.len() - named_n)];
|
||||
let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect();
|
||||
if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) {
|
||||
auto = i == 1;
|
||||
@@ -840,7 +873,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
}
|
||||
// resting on a version shows it in the loupe (nothing is changed)
|
||||
if resp.hovered() && !current {
|
||||
app.hover_preview = Some(crate::HoverPreview { label: format!("Version: {}", v.name), settings: (*v.settings).clone() });
|
||||
app.hover_preview =
|
||||
Some(crate::HoverPreview { label: crate::i18n::tr_format!("Version: {}", v.name), settings: (*v.settings).clone() });
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name");
|
||||
@@ -849,20 +883,20 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
}
|
||||
resp.on_hover_text(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }).context_menu(
|
||||
|ui| {
|
||||
if ui.button("Restore").clicked() {
|
||||
if ui.button(crate::i18n::tr("Restore")).clicked() {
|
||||
let _ = app.run("version.restore", json!({"index": i}));
|
||||
}
|
||||
if ui.button("Update with Current Settings").clicked() {
|
||||
if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() {
|
||||
let _ = app.run("version.update", json!({"index": i}));
|
||||
}
|
||||
if ui.button("Rename…").clicked() {
|
||||
if ui.button(crate::i18n::tr("Rename…")).clicked() {
|
||||
crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name");
|
||||
}
|
||||
if ui.button("Set as Before").clicked() {
|
||||
if ui.button(crate::i18n::tr("Set as Before")).clicked() {
|
||||
let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Delete").clicked() {
|
||||
if ui.button(crate::i18n::tr("Delete")).clicked() {
|
||||
let _ = app.run("version.delete", json!({"index": i}));
|
||||
}
|
||||
},
|
||||
@@ -892,7 +926,7 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
padded(ui, |ui| {
|
||||
if p.history.is_empty() {
|
||||
ui.label(egui::RichText::new("No edits yet.").color(t.text_dim));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim));
|
||||
}
|
||||
for (i, h) in p.history.iter().enumerate().rev() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
@@ -906,15 +940,15 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let _ = app.run("history.restore", json!({"index": i}));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Copy History Step Settings to Before").clicked() {
|
||||
if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() {
|
||||
let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i}));
|
||||
}
|
||||
if ui.button("Create Version from Step").clicked() {
|
||||
if ui.button(crate::i18n::tr("Create Version from Step")).clicked() {
|
||||
let _ = app.run("history.restore", json!({"index": i}));
|
||||
let _ = app.run("version.create", json!({"name": h.label}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Clear History").clicked() {
|
||||
if ui.button(crate::i18n::tr("Clear History")).clicked() {
|
||||
let _ = app.run("history.clear", json!({}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,31 @@ 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::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language);
|
||||
crate::i18n::set_language(app.ui.language);
|
||||
});
|
||||
heading(ui, t, crate::i18n::tr("Library"));
|
||||
let location = match &app.session.library {
|
||||
Some(l) => l.dir.display().to_string(),
|
||||
None => "In memory — nothing is saved".to_string(),
|
||||
None => crate::i18n::tr("In memory — nothing is saved").to_string(),
|
||||
};
|
||||
row(ui, t, "Location", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Location"), |ui| {
|
||||
ui.label(RichText::new(location).color(t.text));
|
||||
});
|
||||
row(ui, t, "", |ui| {
|
||||
let can = app.services.pick_folder.is_some();
|
||||
let r = ui.add_enabled(can, egui::Button::new("Open Library…"));
|
||||
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Open Library…")));
|
||||
register(ui.ctx(), "button:settingsOpenLibrary", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("app.openLibrary", json!({}));
|
||||
}
|
||||
if !can {
|
||||
hint(ui, t, "not available here");
|
||||
hint(ui, t, crate::i18n::tr("not available here"));
|
||||
}
|
||||
});
|
||||
heading(ui, t, "Startup");
|
||||
row(ui, t, "Open in", |ui| {
|
||||
heading(ui, t, crate::i18n::tr("Startup"));
|
||||
row(ui, t, crate::i18n::tr("Open in"), |ui| {
|
||||
choices(
|
||||
ui,
|
||||
"settingsStartup",
|
||||
@@ -118,18 +122,21 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
&mut app.ui.settings.startup_view,
|
||||
);
|
||||
});
|
||||
heading(ui, t, "Culling");
|
||||
heading(ui, t, crate::i18n::tr("Culling"));
|
||||
check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging");
|
||||
check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted");
|
||||
heading(ui, t, "External Editor");
|
||||
row(ui, t, "Application", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text("System default").desired_width(220.0));
|
||||
heading(ui, t, crate::i18n::tr("External Editor"));
|
||||
row(ui, t, crate::i18n::tr("Application"), |ui| {
|
||||
let r = ui
|
||||
.add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text(crate::i18n::tr("System default")).desired_width(220.0));
|
||||
register(ui.ctx(), "field:externalEditor", r.rect);
|
||||
});
|
||||
hint(
|
||||
ui,
|
||||
t,
|
||||
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).",
|
||||
crate::i18n::tr(
|
||||
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,9 +183,9 @@ fn cameras(app: &LightcraftApp) -> Vec<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 +199,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if per {
|
||||
let cams = cameras(app);
|
||||
if cams.is_empty() {
|
||||
hint(ui, t, "No raw photos yet: cameras appear here once their photos are in the library.");
|
||||
hint(ui, t, crate::i18n::tr("No raw photos yet: cameras appear here once their photos are in the library."));
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| {
|
||||
for (i, cam) in cams.iter().enumerate() {
|
||||
@@ -212,10 +219,10 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
};
|
||||
let id = format!("settingsCamera-{i}");
|
||||
let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(current == Some(RAW_DEFAULT), "Same as raw default").clicked() {
|
||||
if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() {
|
||||
pick = Some(json!({"camera": cam, "remove": true}));
|
||||
}
|
||||
if ui.selectable_label(current.is_none(), "LightCraft Default").clicked() {
|
||||
if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() {
|
||||
pick = Some(json!({"camera": cam, "preset": null}));
|
||||
}
|
||||
for p in &app.session.presets {
|
||||
@@ -232,14 +239,14 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
}
|
||||
heading(ui, t, "Other images (JPEG, PNG, TIFF, HEIC…)");
|
||||
row(ui, t, "Non-raw photos", |ui| {
|
||||
heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)"));
|
||||
row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| {
|
||||
if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") {
|
||||
let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}}));
|
||||
}
|
||||
});
|
||||
heading(ui, t, "Metadata");
|
||||
hint(ui, t, "Added to photos you import that don't already have it.");
|
||||
heading(ui, t, crate::i18n::tr("Metadata"));
|
||||
hint(ui, t, crate::i18n::tr("Added to photos you import that don't already have it."));
|
||||
for (key, label, hint_text, value) in
|
||||
[("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())]
|
||||
{
|
||||
@@ -259,14 +266,14 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
}
|
||||
if !app.session.metadata_presets.is_empty() {
|
||||
row(ui, t, "Metadata preset", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Metadata preset"), |ui| {
|
||||
let cur = d.metadata_preset.clone();
|
||||
let mut pick = None;
|
||||
let r = egui::ComboBox::from_id_salt("settingsMetaPreset")
|
||||
.width(240.0)
|
||||
.selected_text(cur.clone().unwrap_or_else(|| "None".into()))
|
||||
.show_ui(ui, |ui| {
|
||||
if ui.selectable_label(cur.is_none(), "None").clicked() {
|
||||
if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() {
|
||||
pick = Some(String::new());
|
||||
}
|
||||
for m in &app.session.metadata_presets {
|
||||
@@ -281,12 +288,12 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
}
|
||||
heading(ui, t, "XMP sidecars");
|
||||
heading(ui, t, crate::i18n::tr("XMP sidecars"));
|
||||
let mut xmp = app.session.xmp;
|
||||
if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") {
|
||||
let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write}));
|
||||
}
|
||||
row(ui, t, "Sidecar names", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Sidecar names"), |ui| {
|
||||
use lightcraft_engine::sidecar::SidecarNaming as N;
|
||||
let mut n = xmp.naming;
|
||||
if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) {
|
||||
@@ -295,12 +302,12 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
heading(ui, t, "Auto Import");
|
||||
hint(ui, t, "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.");
|
||||
row(ui, t, "Watched folder", |ui| {
|
||||
heading(ui, t, crate::i18n::tr("Auto Import"));
|
||||
hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete."));
|
||||
row(ui, t, crate::i18n::tr("Watched folder"), |ui| {
|
||||
ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| "Off".into())).color(t.text));
|
||||
let can = app.services.pick_folder.is_some();
|
||||
let r = ui.add_enabled(can, egui::Button::new("Choose…"));
|
||||
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…")));
|
||||
register(ui.ctx(), "button:settingsAutoFolder", r.rect);
|
||||
if r.clicked()
|
||||
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
|
||||
@@ -308,21 +315,21 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
if d.auto_folder.is_some() && ui.button("Turn Off").clicked() {
|
||||
if d.auto_folder.is_some() && ui.button(crate::i18n::tr("Turn Off")).clicked() {
|
||||
let _ = app.run("library.autoImport", json!({"folder": null}));
|
||||
}
|
||||
});
|
||||
if d.auto_folder.is_some() {
|
||||
row(ui, t, "", |ui| {
|
||||
let mut copy = d.auto_copy;
|
||||
if ui.checkbox(&mut copy, "Copy into the library (else use the files where they are)").changed() {
|
||||
if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() {
|
||||
let _ = app.run("library.autoImport", json!({"copy": copy}));
|
||||
}
|
||||
});
|
||||
row(ui, t, "Album", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Album"), |ui| {
|
||||
let id = egui::Id::new("auto-album");
|
||||
let mut name = ui.data(|m| m.get_temp::<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 +338,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
}
|
||||
if app.session.library.is_none() {
|
||||
hint(ui, t, "In-memory session: these settings last until LightCraft quits.");
|
||||
hint(ui, t, crate::i18n::tr("In-memory session: these settings last until LightCraft quits."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,31 +346,31 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
|
||||
fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
use lightcraft_engine::gpu;
|
||||
heading(ui, t, "Rendering");
|
||||
heading(ui, t, crate::i18n::tr("Rendering"));
|
||||
check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering");
|
||||
let status = if !gpu::available() {
|
||||
match gpu::unavailable_reason() {
|
||||
Some(why) => format!("Rendering on the CPU: {why}"),
|
||||
Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why),
|
||||
None => "No usable GPU found: rendering on the CPU".to_string(),
|
||||
}
|
||||
} else {
|
||||
format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into()))
|
||||
};
|
||||
hint(ui, t, &status);
|
||||
row(ui, t, "Preview size", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Preview size"), |ui| {
|
||||
let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect();
|
||||
let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect();
|
||||
choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge);
|
||||
});
|
||||
hint(ui, t, "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.");
|
||||
row(ui, t, "Memory for caches", |ui| {
|
||||
let auto = format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20);
|
||||
hint(ui, t, crate::i18n::tr("Largest long edge the Detail view renders at; larger is sharper on big displays but slower."));
|
||||
row(ui, t, crate::i18n::tr("Memory for caches"), |ui| {
|
||||
let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20);
|
||||
let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")];
|
||||
choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb);
|
||||
});
|
||||
heading(ui, t, "Thumbnail cache");
|
||||
heading(ui, t, crate::i18n::tr("Thumbnail cache"));
|
||||
let cur = (app.session.cache_bytes() >> 20) as u32;
|
||||
row(ui, t, "Size limit", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Size limit"), |ui| {
|
||||
let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")];
|
||||
let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 };
|
||||
if choices(ui, "settingsCache", &opts, &mut v) {
|
||||
@@ -371,16 +378,16 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
let used = app.session.media.rendered.disk().map(|d| d.size());
|
||||
row(ui, t, "In use", |ui| {
|
||||
row(ui, t, crate::i18n::tr("In use"), |ui| {
|
||||
ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text));
|
||||
let r = ui.button("Clear Cache");
|
||||
let r = ui.button(crate::i18n::tr("Clear Cache"));
|
||||
register(ui.ctx(), "button:settingsClearCache", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("library.clearPreviews", json!({}));
|
||||
}
|
||||
});
|
||||
heading(ui, t, "Local folders");
|
||||
row(ui, t, "Forget unchanged photos", |ui| {
|
||||
heading(ui, t, crate::i18n::tr("Local folders"));
|
||||
row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| {
|
||||
let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")];
|
||||
let mut v = app.session.forget_local_days;
|
||||
if choices(ui, "settingsForgetLocal", &opts, &mut v) {
|
||||
@@ -390,7 +397,9 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
hint(
|
||||
ui,
|
||||
t,
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.",
|
||||
crate::i18n::tr(
|
||||
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.",
|
||||
),
|
||||
);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
smart_previews(app, ui, t);
|
||||
@@ -414,29 +423,40 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if loc.is_null() {
|
||||
return;
|
||||
}
|
||||
heading(ui, t, "Smart previews");
|
||||
heading(ui, t, crate::i18n::tr("Smart previews"));
|
||||
let path = loc["path"].as_str().unwrap_or_default().to_string();
|
||||
let available = loc["available"].as_bool().unwrap_or(true);
|
||||
let custom = loc["custom"].as_bool().unwrap_or(false);
|
||||
row(ui, t, "Folder", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Folder"), |ui| {
|
||||
let text = format!("{path}{}", if custom { "" } else { " (library default)" });
|
||||
ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text);
|
||||
});
|
||||
let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0));
|
||||
if available {
|
||||
hint(ui, t, &format!("{count} smart preview{} · {:.1} MB", if count == 1 { "" } else { "s" }, bytes as f64 / 1048576.0));
|
||||
hint(
|
||||
ui,
|
||||
t,
|
||||
&crate::i18n::tr_format!(
|
||||
"{count} smart preview{} · {:.1} MB",
|
||||
if count == 1 { "" } else { "s" },
|
||||
bytes as f64 / 1048576.0,
|
||||
count = count
|
||||
),
|
||||
);
|
||||
} else {
|
||||
hint(
|
||||
ui,
|
||||
t,
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.",
|
||||
crate::i18n::tr(
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.",
|
||||
),
|
||||
);
|
||||
}
|
||||
// what happens to the previews already built when the folder changes
|
||||
let mode_id = egui::Id::new("smart-existing");
|
||||
let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0);
|
||||
if count > 0 {
|
||||
row(ui, t, "Existing previews", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Existing previews"), |ui| {
|
||||
if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) {
|
||||
ui.data_mut(|d| d.insert_temp(mode_id, mode));
|
||||
}
|
||||
@@ -447,7 +467,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL_W);
|
||||
if app.services.pick_folder.is_some() {
|
||||
let r = ui.button("Choose Folder…");
|
||||
let r = ui.button(crate::i18n::tr("Choose Folder…"));
|
||||
register(ui.ctx(), "button:settingsSmartChoose", r.rect);
|
||||
if r.clicked()
|
||||
&& let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f())
|
||||
@@ -455,7 +475,7 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing})));
|
||||
}
|
||||
}
|
||||
let r = ui.add_enabled(custom, egui::Button::new("Use Library Folder"));
|
||||
let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder")));
|
||||
register(ui.ctx(), "button:settingsSmartReset", r.rect);
|
||||
if r.clicked() {
|
||||
result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing})));
|
||||
@@ -472,22 +492,22 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
("discard", n) => format!("Smart previews folder changed; deleted {n}"),
|
||||
_ => "Smart previews folder changed".to_string(),
|
||||
};
|
||||
app.toast(ui.ctx(), if failed > 0 { format!("{msg} ({failed} failed)") } else { msg });
|
||||
app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg });
|
||||
}
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
hint(ui, t, "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.");
|
||||
hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library."));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------- Interface
|
||||
|
||||
fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
heading(ui, t, "Filmstrip");
|
||||
heading(ui, t, crate::i18n::tr("Filmstrip"));
|
||||
check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names");
|
||||
check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges");
|
||||
heading(ui, t, "Grid");
|
||||
row(ui, t, "Ratings & flags", |ui| {
|
||||
heading(ui, t, crate::i18n::tr("Grid"));
|
||||
row(ui, t, crate::i18n::tr("Ratings & flags"), |ui| {
|
||||
choices(
|
||||
ui,
|
||||
"settingsGridBadges",
|
||||
@@ -496,9 +516,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
);
|
||||
});
|
||||
check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats");
|
||||
heading(ui, t, "Detail");
|
||||
heading(ui, t, crate::i18n::tr("Detail"));
|
||||
check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in");
|
||||
row(ui, t, "Info overlay", |ui| {
|
||||
row(ui, t, crate::i18n::tr("Info overlay"), |ui| {
|
||||
use crate::state::InfoOverlay as I;
|
||||
choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0);
|
||||
if !app.native_menu {
|
||||
// leave room for the in-window menus left of the (centred) search field
|
||||
let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui, app.ui.language) + 24.0;
|
||||
let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0;
|
||||
sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0);
|
||||
}
|
||||
let search_left = full.center().x - sw / 2.0;
|
||||
@@ -59,7 +59,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let empty = app.ui.search.is_empty();
|
||||
if empty && !focused {
|
||||
let g = child.painter().layout_no_wrap("Search Photos".into(), t.font(13.5), t.text_dim);
|
||||
let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim);
|
||||
let w = g.size().x + 24.0;
|
||||
let x0 = sr.center().x - w / 2.0;
|
||||
paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim);
|
||||
@@ -102,7 +102,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas);
|
||||
}
|
||||
let fresp = fresp.on_hover_text(if active > 0 {
|
||||
format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" })
|
||||
crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active)
|
||||
} else {
|
||||
"Filter bar".into()
|
||||
});
|
||||
@@ -113,10 +113,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let mut x = full.right() - 18.0;
|
||||
// saving is failing: the cloud icon turns into a warning until a save succeeds
|
||||
let unsaved = app.session.unsaved().map(|(n, e)| {
|
||||
format!(
|
||||
crate::i18n::tr_format!(
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "it" } else { "them" }
|
||||
if n == 1 { "it" } else { "them" },
|
||||
e = e,
|
||||
n = n
|
||||
)
|
||||
});
|
||||
let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed");
|
||||
|
||||
@@ -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}") });
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
#[serde(default)]
|
||||
#[serde(default = "crate::i18n::default_language")]
|
||||
pub language: crate::i18n::Language,
|
||||
/// The Build Previews run last announced (its identity, finished?).
|
||||
#[serde(skip)]
|
||||
@@ -359,6 +359,9 @@ pub struct UiState {
|
||||
/// Transient toast text and its expiry (seconds of app time).
|
||||
#[serde(skip)]
|
||||
pub toast: Option<(String, f64)>,
|
||||
/// The result of the last Find Missing Photos (it searches in the background).
|
||||
#[serde(skip)]
|
||||
pub last_find_missing: Option<serde_json::Value>,
|
||||
#[serde(skip)]
|
||||
pub status: String,
|
||||
#[serde(skip)]
|
||||
@@ -510,7 +513,7 @@ pub enum Dialog {
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
language: crate::i18n::Language::default(),
|
||||
language: crate::i18n::default_language(),
|
||||
preview_build_seen: None,
|
||||
unsaved_seen: false,
|
||||
luminance_map_restore: None,
|
||||
@@ -589,6 +592,7 @@ impl Default for UiState {
|
||||
compare: None,
|
||||
reference: None,
|
||||
toast: None,
|
||||
last_find_missing: None,
|
||||
status: String::new(),
|
||||
dialog: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Background tasks for commands whose file-system work can take long on a slow or offline drive
|
||||
//! (Find Missing Photos walking a folder, listing the auto-import folder…): the work runs on a
|
||||
//! worker thread and its result is applied on the UI thread between frames, so the window keeps
|
||||
//! answering. Imports, scans, exports and preview builds have their own tasks with progress.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
|
||||
/// Applies a task's result to the app (on the UI thread).
|
||||
type Finish = Box<dyn FnOnce(&mut LightcraftApp, &egui::Context) + Send>;
|
||||
|
||||
struct Task {
|
||||
label: String,
|
||||
rx: Receiver<Finish>,
|
||||
}
|
||||
|
||||
/// The background tasks in flight.
|
||||
#[derive(Default)]
|
||||
pub struct Tasks {
|
||||
running: Vec<Task>,
|
||||
/// Repaints the window when a task finishes (set every frame).
|
||||
pub(crate) repaint: Option<egui::Context>,
|
||||
}
|
||||
|
||||
impl Tasks {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.running.is_empty()
|
||||
}
|
||||
|
||||
/// What is running (`ui.inspect` → `tasks`).
|
||||
pub fn labels(&self) -> Vec<String> {
|
||||
self.running.iter().map(|t| t.label.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn is_running(&self, label: &str) -> bool {
|
||||
self.running.iter().any(|t| t.label == label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `work` on a worker thread, then `done(app, ctx, result)` on the UI thread (in the browser
|
||||
/// build, which has no threads, `work` runs at once and `done` on the next frame).
|
||||
pub fn spawn<T: Send + 'static>(
|
||||
app: &mut LightcraftApp,
|
||||
label: &str,
|
||||
work: impl FnOnce() -> T + Send + 'static,
|
||||
done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
let (tx, rx) = channel::<Finish>();
|
||||
let repaint = app.tasks.repaint.clone();
|
||||
let job = move || {
|
||||
let t = work();
|
||||
let _ = tx.send(Box::new(move |app: &mut LightcraftApp, ctx: &egui::Context| done(app, ctx, t)));
|
||||
if let Some(c) = repaint {
|
||||
c.request_repaint();
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
app.tasks.running.push(Task { label: label.to_string(), rx });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply finished tasks (called every frame).
|
||||
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
app.tasks.repaint = Some(ctx.clone());
|
||||
let mut i = 0;
|
||||
while i < app.tasks.running.len() {
|
||||
match app.tasks.running[i].rx.try_recv() {
|
||||
Ok(finish) => {
|
||||
app.tasks.running.remove(i);
|
||||
finish(app, ctx);
|
||||
}
|
||||
Err(TryRecvError::Empty) => i += 1,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
// the worker died (a panic, already logged)
|
||||
let t = app.tasks.running.remove(i);
|
||||
app.toast(ctx, format!("{} failed", t.label));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !app.tasks.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for every task and apply it (tests, and commands asked to `wait`). `false` on timeout.
|
||||
pub fn wait(app: &mut LightcraftApp, ctx: &egui::Context, timeout: std::time::Duration) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let t0 = std::time::Instant::now();
|
||||
loop {
|
||||
poll(app, ctx);
|
||||
if app.tasks.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if t0.elapsed() > timeout {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// tasks ran inline: their results are already waiting
|
||||
let _ = timeout;
|
||||
poll(app, ctx);
|
||||
app.tasks.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Issue #100: a library that can't be opened at launch is never replaced by a silent in-memory
|
||||
//! demo session. The window says why and offers Try Again / Choose Another Library… / Continue
|
||||
//! Without Saving / Quit; a temporary session shows a banner and never writes to the library.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::panels::library_problem::LibraryProblem;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
fn has(h: &Headless, id: &str) -> bool {
|
||||
h.app.widgets.iter().any(|(w, _)| w == id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unopenable_library_asks_instead_of_running_a_demo() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-libproblem-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let lib = dir.join("lib");
|
||||
std::fs::create_dir_all(&lib).unwrap();
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
let photo = dir.join("a.png");
|
||||
std::fs::write(&photo, png).unwrap();
|
||||
|
||||
// another program has the library open: the launch fails like the desktop host's would
|
||||
let held = lightcraft_catalog::LibraryLock::acquire(&lib, "another LightCraft").unwrap();
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
let err = session.open_library(&lib, true).unwrap_err().to_string();
|
||||
let png = |img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
};
|
||||
let mut app = LightcraftApp::new(
|
||||
session,
|
||||
Services {
|
||||
png: Some(Box::new(png)),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
app.library_problem =
|
||||
Some(LibraryProblem { pending_import: vec![photo.to_string_lossy().to_string()], ..LibraryProblem::new(lib.to_string_lossy(), err) });
|
||||
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.app.session.catalog.is_empty(), "no demo photos");
|
||||
for b in ["button:libraryRetry", "button:libraryChoose", "button:libraryTemporary", "button:libraryQuit"] {
|
||||
assert!(has(&h, b), "{b}");
|
||||
}
|
||||
if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") {
|
||||
let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("dialog.png").to_string_lossy(), "headless": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
let inspect = h.request("ui.inspect", json!({}), T);
|
||||
let p = &inspect["result"]["libraryProblem"];
|
||||
assert!(p["error"].as_str().unwrap().contains("already open in another LightCraft"), "{p}");
|
||||
assert_eq!(p["temporarySession"], false);
|
||||
|
||||
// still locked: Try Again keeps the window, with the error
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true);
|
||||
h.step();
|
||||
assert!(h.app.session.library.is_none());
|
||||
assert!(has(&h, "button:libraryRetry"));
|
||||
|
||||
// Continue Without Saving: a banner, the command-line photo in the temporary session, and
|
||||
// nothing written into the library folder
|
||||
let before: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryTemporary"}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(!has(&h, "button:libraryRetry"));
|
||||
assert!(has(&h, "indicator:temporarySession"));
|
||||
assert!(h.app.session.library.is_none());
|
||||
assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the temporary session");
|
||||
if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") {
|
||||
let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("banner.png").to_string_lossy(), "headless": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 3}}), T);
|
||||
let after: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(before, after, "the temporary session never writes to the library");
|
||||
|
||||
// the other program quits; Open Library… → Try Again opens it, the banner goes away
|
||||
drop(held);
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryReopen"}), T)["ok"], true);
|
||||
h.step();
|
||||
assert!(has(&h, "button:libraryRetry"));
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.app.session.library.is_some());
|
||||
assert!(h.app.library_problem.is_none());
|
||||
assert!(!has(&h, "indicator:temporarySession") && !has(&h, "button:libraryRetry"));
|
||||
assert!(h.app.session.catalog.is_empty(), "an opened (new) library is never seeded with demo photos here");
|
||||
drop(h);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Slow or offline drives never stall a frame (issue #104): with an injected file-system probe
|
||||
//! that takes far longer than a frame (a sleeping NAS), the grid, the Info panel, the Missing
|
||||
//! Photos view, the Rename dialog and an import keep drawing frames quickly, the probe never runs
|
||||
//! on the UI thread, and the background answers still arrive.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::ThreadId;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use lightcraft_engine::{LibrarySource, Session};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::{RightPanel, ViewMode};
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
/// What one file-system call takes on the "NAS".
|
||||
const SLOW: Duration = Duration::from_millis(400);
|
||||
/// A frame that waited for one such call would take at least this long.
|
||||
const FRAME_LIMIT: Duration = Duration::from_millis(300);
|
||||
const N: u64 = 12;
|
||||
|
||||
/// Threads the probe ran on, and how often.
|
||||
#[derive(Clone, Default)]
|
||||
struct Calls {
|
||||
threads: Arc<Mutex<Vec<ThreadId>>>,
|
||||
n: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Calls {
|
||||
fn record(&self) {
|
||||
self.threads.lock().unwrap().push(std::thread::current().id());
|
||||
self.n.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn on(&self, t: ThreadId) -> usize {
|
||||
self.threads.lock().unwrap().iter().filter(|x| **x == t).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// `N` photos whose originals are on an unreachable share, a smart previews folder, and a probe
|
||||
/// that sleeps like a NAS waking up before saying "not there".
|
||||
fn offline_library(calls: &Calls) -> Session {
|
||||
let mut s = Session::new();
|
||||
for i in 0..N {
|
||||
let path = format!("/lightcraft-offline-nas/IMG_{i:03}.jpg");
|
||||
let p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:03}.jpg"), "JPEG", 600, 400, "2026-01-01T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
s.media.smart_dir = Some(std::env::temp_dir().join("lightcraft-offline-smart-previews-none"));
|
||||
let c = calls.clone();
|
||||
s.media.availability.set_probe(Arc::new(move |_| {
|
||||
c.record();
|
||||
std::thread::sleep(SLOW);
|
||||
false
|
||||
}));
|
||||
s
|
||||
}
|
||||
|
||||
fn max_frame(h: &mut Headless, frames: usize) -> Duration {
|
||||
let mut worst = Duration::ZERO;
|
||||
for _ in 0..frames {
|
||||
let t0 = Instant::now();
|
||||
h.step();
|
||||
worst = worst.max(t0.elapsed());
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_originals_never_block_a_frame() {
|
||||
let calls = Calls::default();
|
||||
let app = LightcraftApp::new(offline_library(&calls), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
let ui_thread = std::thread::current().id();
|
||||
h.app.ui.view = ViewMode::PhotoGrid;
|
||||
h.step();
|
||||
assert!(h.app.session.media.availability.is_background(), "the app checks files off the UI thread");
|
||||
|
||||
// the grid: every cell asks for its thumbnail (which asks whether the original is there)
|
||||
h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap();
|
||||
let worst = max_frame(&mut h, 20);
|
||||
assert!(worst < FRAME_LIMIT, "grid frame took {worst:?}");
|
||||
|
||||
// the Info panel of the active photo, in the loupe
|
||||
h.app.ui.view = ViewMode::Detail;
|
||||
h.app.ui.right = RightPanel::Info;
|
||||
let worst = max_frame(&mut h, 20);
|
||||
assert!(worst < FRAME_LIMIT, "Info panel frame took {worst:?}");
|
||||
|
||||
// the Missing Photos view fills in as the background checks find the files gone
|
||||
h.app.ui.view = ViewMode::PhotoGrid;
|
||||
h.app.ui.right = RightPanel::None;
|
||||
h.app.session.execute("library.source", &json!({"kind": "missing"})).unwrap();
|
||||
assert_eq!(h.app.session.source, LibrarySource::Missing);
|
||||
let t0 = Instant::now();
|
||||
let mut worst = Duration::ZERO;
|
||||
while h.app.session.visible().len() < N as usize && t0.elapsed() < Duration::from_secs(60) {
|
||||
let f = Instant::now();
|
||||
h.step();
|
||||
worst = worst.max(f.elapsed());
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(h.app.session.visible().len(), N as usize, "every photo found missing in the end");
|
||||
assert!(worst < FRAME_LIMIT, "Missing Photos frame took {worst:?}");
|
||||
|
||||
// the Rename dialog's preview (it checks which names are taken on disk)
|
||||
h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap();
|
||||
h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 });
|
||||
let worst = max_frame(&mut h, 20);
|
||||
assert!(worst < FRAME_LIMIT, "Rename dialog frame took {worst:?}");
|
||||
|
||||
assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked");
|
||||
assert_eq!(calls.on(ui_thread), 0, "the slow probe never ran on the UI thread");
|
||||
}
|
||||
|
||||
/// An import whose files take long to read (probe: decoding headers and hashing on a slow drive)
|
||||
/// runs on a worker thread: frames stay quick, the probe never runs on the UI thread, and Cancel
|
||||
/// stops it with what was read so far added.
|
||||
#[test]
|
||||
fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-slow-import-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let n = 40;
|
||||
for i in 0..n {
|
||||
std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap();
|
||||
}
|
||||
let calls = Calls::default();
|
||||
let mut s = Session::new();
|
||||
let c = calls.clone();
|
||||
s.media.file_probe = Some(Arc::new(move |p: &str| {
|
||||
c.record();
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
Ok(lightcraft_engine::media::ProbeInfo {
|
||||
width: 60,
|
||||
height: 40,
|
||||
format: "JPEG".into(),
|
||||
content_hash: Some(p.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}));
|
||||
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
let ui_thread = std::thread::current().id();
|
||||
h.step();
|
||||
let undo0 = h.app.session.undo.len();
|
||||
crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();
|
||||
// some batches arrive; meanwhile every frame is quick
|
||||
let t0 = Instant::now();
|
||||
let mut worst = Duration::ZERO;
|
||||
while h.app.import.as_ref().is_some_and(|t| t.imported < 8) && t0.elapsed() < Duration::from_secs(60) {
|
||||
let f = Instant::now();
|
||||
h.step();
|
||||
worst = worst.max(f.elapsed());
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(worst < FRAME_LIMIT, "import frame took {worst:?}");
|
||||
assert!(h.app.import.is_some(), "still importing");
|
||||
let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default();
|
||||
assert!(status["total"].as_u64().unwrap_or(0) >= 8, "{status}");
|
||||
// Cancel: the files being read finish, nothing new starts
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:importCancel"}), Duration::from_secs(10));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let t0 = Instant::now();
|
||||
while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(60) {
|
||||
h.step();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(h.app.import.is_none(), "stopped");
|
||||
let added = h.app.session.catalog.len();
|
||||
assert!((8..n).contains(&added), "cancelled part-way: {added} of {n} added");
|
||||
assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step");
|
||||
assert_eq!(calls.on(ui_thread), 0, "files were never read on the UI thread");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Issue #103: quitting with changes that couldn't be saved asks first (after one more try), and
|
||||
//! settings-file problems found when the library opened are shown, not swallowed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::{MemStore, Store};
|
||||
use lightcraft_engine::library::LibraryStores;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Every write fails while `fail` is set (an unplugged drive).
|
||||
#[derive(Clone, Default)]
|
||||
struct Unplugged {
|
||||
files: MemStore,
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Unplugged {
|
||||
fn check(&self) -> std::io::Result<()> {
|
||||
if self.fail.load(Ordering::SeqCst) { Err(std::io::Error::other("drive unplugged")) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Unplugged {
|
||||
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
|
||||
self.files.read(name)
|
||||
}
|
||||
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.check()?;
|
||||
self.files.write_atomic(name, data)
|
||||
}
|
||||
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.check()?;
|
||||
self.files.append(name, data)
|
||||
}
|
||||
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
|
||||
self.files.truncate(name, len)
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"unplugged".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn has(h: &Headless, id: &str) -> bool {
|
||||
h.app.widgets.iter().any(|(w, _)| w == id)
|
||||
}
|
||||
|
||||
/// Click a widget and let the synthetic events (move, press, release) play out.
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
// a window that just appeared is placed on its second frame
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": id}), T)["ok"], true, "{id}");
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_with_unsaved_changes_asks_first() {
|
||||
let store = Unplugged::default();
|
||||
let mut s = lightcraft_engine::Session::new();
|
||||
let stores = LibraryStores { dir: "unplugged".into(), catalog: Box::new(store.clone()), files: Box::new(MemStore::new()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.step();
|
||||
// nothing unsaved: closing is fine
|
||||
assert!(crate::panels::notices::may_close(&mut h.app));
|
||||
|
||||
store.fail.store(true, Ordering::SeqCst);
|
||||
let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Kept"}}), T);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app), "retried, still failing: ask");
|
||||
h.step();
|
||||
let prompt = h.app.quit_prompt.clone().unwrap();
|
||||
assert!(prompt.contains("1 change couldn't be written to disk") && prompt.contains("drive unplugged"), "{prompt}");
|
||||
for b in ["button:quitRetry", "button:quitAnyway", "button:quitCancel"] {
|
||||
assert!(has(&h, b), "{b}");
|
||||
}
|
||||
assert_eq!(h.request("ui.inspect", json!({}), T)["result"]["quitPrompt"], json!(prompt));
|
||||
// Cancel: back to work, nothing quit
|
||||
click(&mut h, "button:quitCancel");
|
||||
assert!(h.app.quit_prompt.is_none() && !h.app.quit_confirmed);
|
||||
|
||||
// the drive is back: Try Saving Again saves and quits
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app));
|
||||
h.step();
|
||||
store.fail.store(false, Ordering::SeqCst);
|
||||
click(&mut h, "button:quitRetry");
|
||||
assert!(h.app.session.unsaved().is_none(), "saved");
|
||||
assert!(h.app.quit_prompt.is_none() && !h.app.quit_confirmed);
|
||||
|
||||
// Quit Anyway is a deliberate choice
|
||||
store.fail.store(true, Ordering::SeqCst);
|
||||
let _ = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Lost"}}), T);
|
||||
assert!(!crate::panels::notices::may_close(&mut h.app));
|
||||
h.step();
|
||||
click(&mut h, "button:quitAnyway");
|
||||
assert!(h.app.quit_confirmed && crate::panels::notices::may_close(&mut h.app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_settings_file_is_shown() {
|
||||
let files = MemStore::new();
|
||||
files.set("presets.json", b"{\"user\": [ {\"id\": ".to_vec());
|
||||
let mut s = lightcraft_engine::Session::new();
|
||||
let stores = LibraryStores { dir: "lib".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, false).unwrap();
|
||||
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.notices.len(), 1);
|
||||
assert!(h.app.notices[0].contains("presets.json is damaged"), "{:?}", h.app.notices);
|
||||
assert!(has(&h, "button:noticeOk"));
|
||||
assert!(files.files.lock().unwrap().keys().any(|k| k.starts_with("presets.json.corrupt-")));
|
||||
click(&mut h, "button:noticeOk");
|
||||
assert!(h.app.notices.is_empty(), "{:?}", h.app.notices);
|
||||
h.step();
|
||||
assert!(!has(&h, "button:noticeOk"));
|
||||
}
|
||||
@@ -110,17 +110,16 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
|
||||
fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))));
|
||||
fonts.font_data.insert("Japanese".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/BIZUDPGothic-Regular.ttf"))));
|
||||
fonts.font_data.insert("Japanese-Bold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/BIZUDPGothic-Bold.ttf"))));
|
||||
let fallback: Vec<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.font_data.insert("japanese".into(), Arc::new(FontData::from_static(lightcraft_engine::export::WATERMARK_JAPANESE_FONT)));
|
||||
for stack in fonts.families.values_mut() {
|
||||
stack.push("japanese".into());
|
||||
}
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().push("Japanese".into());
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Sony ARW starting look
|
||||
|
||||
ARW decoding supplies a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The ARW loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost.
|
||||
|
||||
## Colour and tone are separate
|
||||
|
||||
A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. At least 256 paired pixels, 5% coloured samples and a nonsingular input covariance with bounded condition are required.
|
||||
|
||||
Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. Luminance is fitted separately: 32 equal-population bins with robust median knots and pooled-adjacent-violators isotonic regression. The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder.
|
||||
|
||||
The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged.
|
||||
|
||||
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). These are provisional acceptance gates, not a colour-accuracy certification. Monochrome, singular, unrelated, aspect-mismatched and poor-fitting references retain the documented fallback. Both fits use the same fixed proxy at every decode resolution.
|
||||
|
||||
## Source metadata and white balance
|
||||
|
||||
Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render jobs used header-only facts (including a hard-coded RAW WB). Decoded pixels and source facts now travel and remain cached together, including across web-worker renders. Smart previews keep the tone curve in their header (the matrix is already in their pixels), so an offline original renders and exports with the same look; proxies built before this fall back to the generic curve until rebuilt. Pixel-statistics/colour-sampling commands use decoded facts too. Render cache version 6 invalidates old thumbnails and loupe renders.
|
||||
|
||||
Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs now keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Measured/absolute-Kelvin Sony WB remains future camera-calibration work.
|
||||
|
||||
Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust.
|
||||
|
||||
## Validation and scope
|
||||
|
||||
Synthetic tests cover colour with a nonlinear camera tone, preserved scene-linear headroom, invalid/singular/unrelated references, monotone black-preserving curves, hostile serialized knots, decode-to-render/cache metadata propagation, old-catalog WB transitions and CPU/GPU tone equivalence. Private photos are never committed or uploaded.
|
||||
|
||||
Five local Sony ILCE-6700 ARWs were accepted. Held-out per-channel linear-display RMS values were 0.04244, 0.03349, 0.04552, 0.02871 and 0.03100. All five unedited supplied JPEG exports stayed pixel-identical to the installed pre-change renderer at equal size/quality. Four supplied JPEGs use Display P3 and two have a narrower crop than the RAW, so they are visual references rather than aligned pixel ground truth.
|
||||
|
||||
Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. Rejected and unchanged: the RX100M3 (held-out RMS 0.103; its fallback render already has a strong green cast), an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow).
|
||||
|
||||
On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint, highlights and shadows all changed the output. The same matrix and curve were selected for 400-pixel and full-resolution renders. Headless UI checks wait until the loupe reports `source: render` and no jobs remain; the camera JPEG stand-in does not count as verification.
|
||||
|
||||
This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user