diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..9e9702005 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,69 @@ + + +Fixes # + + + +## Problem + + + +## Change + + + +**Not in this PR:** + +## How it was checked + +- [ ] `cargo xtask ci` passes on this branch, rebased on current `main` +- [ ] A test that fails without this change (for a crash fix: a regression test that panicked before it) +- [ ] User-visible change: ran the app and looked at the result + + + +## Screenshots + + + +## Merging + + + +- **Overlap:** +- **Existing edits and libraries:** +- **Shared numbers taken:** +- **Where it came from:** +- **New dependencies, `unsafe`, build scripts, network access, spawned processes, deleted files, CI or `xtask` gate + changes:** + +## Checklist + +- [ ] No new panic path in non-test code: no `unwrap` / `expect` / `panic!`, no indexing or unchecked arithmetic on a + number from a file, a person or an agent (`get`, `checked_mul`, NaN and char boundaries handled) +- [ ] New text on screen goes through `tr()` and has its key in `crates/ui-egui/locales/*.json`; a new shortcut is + free in `docs/library-shortcuts.md` +- [ ] The feature's rows in `docs/parity-checklist.md` and its entry in `docs/gaps.md` are updated, honestly (🟡 when + it is partial), in the same commit +- [ ] New code has a caller: no command, module or setting that nothing reaches yet +- [ ] Every new image, icon or other asset has its entry in `assets/ATTRIBUTION.md`; no font or media files added +- [ ] Ready to merge (otherwise open it as a draft) diff --git a/CLAUDE.md b/CLAUDE.md index d2d538d65..416ffc0a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` `Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row. - **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match. - **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). -- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). +- **Quality gates** before every commit: `cargo xtask ci --quick` (fmt, clippy -D warnings, the changed packages' + tests, layers, assets, and heif / parity / wasm when touched). GitHub runs the full `cargo xtask ci` on every PR + (`.github/workflows/ci.yml`); run the full one locally before merging branches (below) or when `--quick` can't see + the risk. One `ci` runs at a time per machine; others wait their turn (`xtask/src/ci_lock.rs`). - **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment. ## Assets: icons, images, fonts (ABSOLUTE RULE — never violate) diff --git a/Cargo.lock b/Cargo.lock index 8eab2b93f..4c745676a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1085,7 +1085,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", "serde", @@ -1347,7 +1347,7 @@ dependencies = [ [[package]] name = "ecolor" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1380,7 +1380,7 @@ dependencies = [ [[package]] name = "eframe" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1416,7 +1416,7 @@ dependencies = [ [[package]] name = "egui" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1435,7 +1435,7 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "egui_glow" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1492,7 +1492,7 @@ dependencies = [ [[package]] name = "egui_kittest" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "emath" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1586,7 +1586,7 @@ dependencies = [ [[package]] name = "epaint" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1612,7 +1612,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -3371,7 +3371,7 @@ version = "0.13.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", - "quick-xml 0.38.4", + "quick-xml", ] [[package]] @@ -3409,7 +3409,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-tiff", "proptest", - "quick-xml 0.38.4", + "quick-xml", "serde", "serde_json", ] @@ -3418,6 +3418,7 @@ dependencies = [ name = "lightcraft-pipeline" version = "0.13.0" dependencies = [ + "dhat", "image", "lightcraft-color", "lightcraft-develop", @@ -4884,15 +4885,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -6412,7 +6404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] @@ -6933,7 +6925,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -7419,19 +7411,19 @@ dependencies = [ [[patch.unused]] name = "egui_demo_lib" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[patch.unused]] name = "egui_extras" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[patch.unused]] name = "egui_inspection" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[patch.unused]] name = "egui_system_font_provider" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index aca12d197..76a6ad78d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,11 @@ members = ["crates/*", "apps/*", "xtask"] version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } @@ -60,7 +60,7 @@ libc = "0.2" # dev-only: process CPU time in benches dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds # formats -quick-xml = "0.38" +quick-xml = "0.41" miniz_oxide = "0.8" # codecs @@ -175,18 +175,18 @@ miniz_oxide = { opt-level = 3 } # Keep the complete egui family and native event bridge on reviewed fork commits. [patch.crates-io] -ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 8b16eeba0..c18729355 100644 --- a/NOTICE +++ b/NOTICE @@ -50,11 +50,15 @@ maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implement no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included. Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases) -also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md -(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans -Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto -Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's -licence as OFL-.txt; Windows installers include their texts in FONT-LICENCES.txt. +also embed the default fonts listed in +https://github.com/storytold/craft-fonts/blob/b076417b5880bd4154bd22f00695b1d28c6bea83/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, +Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian, +NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2, Noto Sans Hebrew and +Noto Sans Devanagari), under the SIL Open Font License 1.1. The opt-in LXGW WenKai Lite face +(fonts/simplified-chinese-manifest.txt in craft-fonts) is not embedded. The web build embeds +BIZ UDPGothic Regular, Noto Sans Thai Regular and Noto Sans Georgian Regular, and loads Noto Sans +CJK SC separately. Release packages include each font's licence as OFL-.txt; Windows +installers include their texts in FONT-LICENCES.txt. Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and are never distributed with it. On the user's request (Photo > Download Place Names) the app @@ -62,3 +66,21 @@ downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoName (https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/); attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own. + + +The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts +and registered through egui's default font definitions. They derive from emoji-icon-font by +John Slegers, under the MIT License. The subset and full face share the following upstream notice. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Original: https://github.com/jslegers/emoji-icon-font +The following text is reproduced verbatim from emoji-icon-font-mit-license.txt: + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index dd7c538f7..78b3b3234 100644 --- a/README.md +++ b/README.md @@ -405,8 +405,17 @@ cargo run --release -p lightcraft -- --control 7980 # with the automation ch cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM +cargo xtask ci --quick # only what your branch's changes can break (minutes) ``` +`cargo xtask ci --quick` compares with the merge base of `origin/main` (`--base REF` for another) +and runs fmt, clippy, the tests of the changed packages, layers and assets, plus HEIF, parity and +the wasm check when the changes touch them; a change to `Cargo.toml`, `Cargo.lock` or the lint +config tests everything. GitHub runs the full `cargo xtask ci` on every pull request +(`.github/workflows/ci.yml`). Only one `ci` runs at a time per machine: a second one, from another +worktree say, waits for the first and names it, since several at once take longer in total than in +turn. `LIGHTCRAFT_CI_SLOTS=2` allows two; `LIGHTCRAFT_CI_LOCK=off` skips the lock. + CI defaults to line-table debug information and one build job per 1.5 GB of RAM available when it starts (at most one per CPU; 4 if memory can't be read), so an 8 GB machine with 6 GB free builds with 4, and a busy machine with little free diff --git a/README.zh-hans.md b/README.zh-hans.md index bc2f2830e..2800db410 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -298,10 +298,10 @@ lightcraft --control 7980 ~/Pictures/trip ## Feature status -LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)): +LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md)): - **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在 - [docs/parity.md](docs/parity.md)。 + [docs/parity-checklist.md](docs/parity-checklist.md)。 - **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** @@ -309,7 +309,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** -- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。 +- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#upcoming)。 | 领域 | 状态 | |---|---| @@ -333,7 +333,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( | 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ | | 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ | | GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 | -| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) | +| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#upcoming)) | | 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 | ✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始 diff --git a/ROADMAP.md b/ROADMAP.md index 6d8720b6e..6802f9234 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -114,6 +114,11 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-22: Print page setup and PDF output gain the Japanese photo and postcard papers — L判 + (89 × 127 mm), 2L判 (127 × 178), KG判 (102 × 152), はがき (100 × 148), 往復はがき (148 × 200), + 六切 (203 × 254), 四切 (254 × 305) and 名刺 (55 × 91) — at their exact metric sizes. The Print dialog + and `print.set` take their customary names, and names are matched case-, space- and + punctuation-insensitively (`L判`, `l`, `L-size` are one paper). - 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277. @@ -186,6 +191,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Print: Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) in the page setup, Print dialog and PDF output; headline numbers unchanged | | 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present | | 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | | 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged | diff --git a/apps/lightcraft-web/src/automation.rs b/apps/lightcraft-web/src/automation.rs new file mode 100644 index 000000000..ff11e0ee9 --- /dev/null +++ b/apps/lightcraft-web/src/automation.rs @@ -0,0 +1,48 @@ +//! The page's automation entry point, `lightcraft.command(id, params)` (see `web.rs`): what the +//! params text means. Plain Rust, so it is tested natively. + +use serde_json::Value; + +/// The command parameters in `text`: a JSON object, or `{}` when the text is empty or blank (or +/// `null`). Anything else is an error, never `{}`: a command run without its params acts on the +/// selection or with defaults, so a typo such as `{ids: [3]}` must not delete or reset the +/// selected photos. The control channel and MCP refuse such params the same way. +pub fn params(text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Ok(Value::Object(Default::default())); + } + match serde_json::from_str::(text) { + Ok(Value::Null) => Ok(Value::Object(Default::default())), + Ok(v @ Value::Object(_)) => Ok(v), + Ok(_) => Err("params must be a JSON object, e.g. {\"ids\": [3]}".into()), + Err(e) => Err(format!("params are not valid JSON ({e}); nothing was run")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn blank_or_null_params_are_an_empty_object() { + for text in ["", " ", "\n", "null", " null "] { + assert_eq!(params(text), Ok(json!({})), "{text:?}"); + } + } + + #[test] + fn an_object_is_passed_through() { + assert_eq!(params(r#"{"ids": [3], "rating": 5}"#), Ok(json!({"ids": [3], "rating": 5}))); + } + + /// Before, any of these ran the command with `{}`: `photo.delete` then removed the selection. + #[test] + fn invalid_json_or_a_non_object_is_refused() { + for text in ["{ids: [3]}", "{\"ids\": [3]", "ids=3", "[3]", "3", "\"{}\"", "true"] { + assert!(params(text).is_err(), "{text:?} must not run the command"); + } + assert!(params("{ids: [3]}").unwrap_err().contains("nothing was run")); + } +} diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs index 58966e6e8..f984cb34c 100644 --- a/apps/lightcraft-web/src/lib.rs +++ b/apps/lightcraft-web/src/lib.rs @@ -16,6 +16,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod automation; pub mod backup; pub mod bench; pub mod files; diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 4f8059c53..656f8645d 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -23,7 +23,11 @@ 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"; +/// The web picker's accept list, derived from the engine's import extensions (both +/// cases) so no format the engine can import is silently filtered out of the dialog. +fn accept_list() -> String { + lightcraft_engine::import::accept_list() +} /// After a failed save, wait this long before trying again (ms). const SAVE_RETRY_MS: f64 = 2000.0; @@ -123,7 +127,7 @@ fn open_picker(originals: Originals, backend: Option, ctx: egui::Contex let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; input.set_type("file"); input.set_multiple(true); - input.set_accept(ACCEPT); + input.set_accept(&accept_list()); let inp = input.clone(); let on_change = Closure::::new(move || { if let Some(files) = inp.files() { @@ -276,6 +280,8 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc, first_frame_logged: bool, + /// The page title last set (`document.title`), so it is written only when it changes. + title: String, last_save: f64, ui_written: Vec, } @@ -504,6 +512,7 @@ impl WebApp { lib_dir, bench: opts.bench.then(|| Bench::new(origin)), first_frame_logged: false, + title: String::new(), last_save: 0.0, ui_written, } @@ -513,22 +522,37 @@ impl WebApp { /// hidden logic tick), and add originals whose bytes have arrived. fn import_dropped(&mut self, ctx: &egui::Context) { let dropped = self.app.take_dropped_files(); + // a dialog is on screen (the card said so while the files hovered): nothing is read or imported + // (files read from an earlier drop still finish below) + if !dropped.is_empty() && self.app.ui.dialog.is_some() { + self.app.toast(ctx, lightcraft_ui_egui::i18n::tr("Close the open dialog, then drop the files again")); + return; + } for f in dropped { let (originals, backend, ctx) = (self.originals.clone(), self.backend.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { let name = egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string(); match egui::DroppedFile::bytes_async(f.as_ref()).await { Ok(bytes) => store_file(originals, backend, name, bytes, ctx).await, - Err(e) => log::warn!("reading dropped {name}: {e}"), + Err(e) => notice(lightcraft_ui_egui::i18n::dropped_file_unreadable(&name, &e.to_string())), } }); } let paths = self.originals.take_pending(); if !paths.is_empty() { - let n = paths.len(); match self.app.run("library.import", json!({"paths": paths})) { - Ok(_) => self.app.toast(ctx, format!("Added {n} photo{}", if n == 1 { "" } else { "s" })), - Err(e) => log::warn!("import: {e}"), + // what the import did, not what was submitted: duplicates and unsupported files add nothing, + // a drop that only finds duplicates says nothing (as the desktop import's toast does when it + // shows where they are), and one whose files could not be read says so + Ok(report) => { + if let Some(notice) = lightcraft_ui_egui::i18n::dropped_photos_outcome(&report) { + self.app.toast(ctx, notice); + } + } + Err(e) => { + log::warn!("import: {e}"); + self.app.toast(ctx, lightcraft_ui_egui::i18n::dropped_import_failed(&e)); + } } } } @@ -644,8 +668,11 @@ impl WebApp { fn run_inbox(&mut self) { let inbox = INBOX.with(|q| std::mem::take(&mut *q.borrow_mut())); for (id, params, (resolve, reject)) in inbox { - let params: serde_json::Value = serde_json::from_str(¶ms).unwrap_or(json!({})); - let r = if id == "web.stats" { Ok(self.web_stats()) } else { self.app.run(&id, params) }; + let r = match crate::automation::params(¶ms) { + Err(e) => Err(format!("{id}: {e}")), + Ok(_) if id == "web.stats" => Ok(self.web_stats()), + Ok(params) => self.app.run(&id, params), + }; let _ = match r { Ok(v) => resolve.call1(&JsValue::NULL, &v.to_string().into()), Err(e) => reject.call1(&JsValue::NULL, &e.into()), @@ -680,6 +707,14 @@ impl eframe::App for WebApp { let ctx = ui.ctx().clone(); self.import_dropped(&ctx); self.app.ui(ui); + // eframe's web runner ignores `ViewportCommand::Title`: name the open photo in the page title + let title = self.app.page_title(self.files.is_dirty() || self.flushing.get()); + if title != self.title + && let Some(doc) = window().and_then(|w| w.document()) + { + doc.set_title(&title); + self.title = title; + } // The benchmark is a UI-phase step: a second layout pass or a hidden tick must not count a frame. if ui.ctx().current_pass_index() == 0 { if let Some(b) = self.bench.as_mut() diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 08f9e898d..53d389b6f 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -522,7 +522,7 @@ mod tests { } }; frame(&mut watch, &mut index); // an empty filtered view: no render request yet - cache.clear(); // File ▸ Clear Preview Cache + cache.clear(); // File ▸ Discard Preview Cache frame(&mut watch, &mut index); // the filter is removed: the first thumbnail request (`Workers::try_start`) gone.extend(watch.observe(&cache, cache.generation(), &mut index)); diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index c7244c703..95aa10896 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -247,6 +247,9 @@ mod tests { let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); let (stream, _) = listener.accept().unwrap(); + // Small test messages must not wait for Nagle's algorithm or a delayed ACK. + client.set_nodelay(true).unwrap(); + stream.set_nodelay(true).unwrap(); before(&mut client); let (tx, requests) = channel(); let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap(); @@ -316,6 +319,10 @@ mod tests { // A dispatched command waits for its reply, not for socket input. std::thread::sleep(Duration::from_millis(250)); req.reply.send(json!({"ok": true, "result": id})).unwrap(); + if id == 2 { + // The worker must reach EOF even before the client reads its final reply. + connection.join(); + } let mut reply = String::new(); connection.client.read_line(&mut reply).unwrap(); let reply: Value = serde_json::from_str(&reply).unwrap(); diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs index cf6d66079..3ca748f47 100644 --- a/apps/lightcraft/src/editors.rs +++ b/apps/lightcraft/src/editors.rs @@ -31,22 +31,14 @@ pub fn check(app: &str) -> Result<(), String> { /// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside. pub fn open_with(path: &str, app: &str) -> Result<(), String> { let mut c = match launcher(app)? { - Launcher::Default if cfg!(target_os = "macos") => { - let mut c = Command::new("open"); - c.arg(path); - c - } + Launcher::Default if cfg!(target_os = "macos") => return run_open(&[path]), Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path), Launcher::Default => { let mut c = Command::new("xdg-open"); c.arg(path); c } - Launcher::MacApp(app) => { - let mut c = Command::new("open"); - c.args(["-a", &app, path]); - c - } + Launcher::MacApp(app) => return run_open(&["-a", &app, path]), Launcher::Program(p) => { let mut c = Command::new(p); c.arg(path); @@ -61,6 +53,22 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> { c.spawn().map(|_| ()).map_err(|e| e.to_string()) } +/// macOS: run `open` and wait for it (it returns as soon as LaunchServices has handed the file +/// over), so an application it can't find or start is reported instead of silently doing nothing. +fn run_open(args: &[&str]) -> Result<(), String> { + let out = Command::new("open").args(args).output().map_err(|e| e.to_string())?; + open_result(out.status.success(), &String::from_utf8_lossy(&out.stderr)) +} + +/// What `open` said, as a result: its own message when it failed. +fn open_result(ok: bool, stderr: &str) -> Result<(), String> { + if ok { + return Ok(()); + } + let msg = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("the application couldn't be started"); + Err(msg.to_string()) +} + /// Open every file in `paths` with one start of `app`, as a file manager hands over a selection: /// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is /// still starting when it is started once per file. The system default opens them one by one @@ -119,10 +127,15 @@ fn launcher(app: &str) -> Result { fn find_photocraft() -> Option { let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string()); if cfg!(target_os = "macos") { - // LaunchServices finds the app wherever it is; these are only the usual places + // the usual places, then wherever Spotlight knows it (by bundle id or bundle name) let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app")); - let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir()); - return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string()))); + let usual = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir()); + let spotlight = || { + let query = format!("kMDItemCFBundleIdentifier == '{PHOTOCRAFT_ID}' || kMDItemFSName == 'PhotoCraft.app'"); + let out = Command::new("mdfind").arg(query).output().ok().filter(|o| o.status.success())?; + mdfind_app(&String::from_utf8_lossy(&out.stdout)) + }; + return usual.or_else(spotlight).map(|p| Launcher::MacApp(p.to_string_lossy().to_string())); } if cfg!(target_os = "windows") { let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir")); @@ -130,16 +143,34 @@ fn find_photocraft() -> Option { return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program); } // Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which - // registers one on first run), else the Flatpak - let on_path = - env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::>()).map(|d| d.join("photocraft")).find(|p| p.is_file()); + // registers one on first run; its Exec a path or a name on the PATH), an AppImage where people + // keep them, else the Flatpak + let path_dirs: Vec = env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::>()).collect(); + let on_path = |name: &Path| resolve_program(name, &path_dirs); let from_entry = || { desktop_entry_dirs(env).into_iter().find_map(|d| { let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?; - desktop_exec(&text).filter(|p| p.is_file()) + desktop_exec(&text) + // a wrapper (`Exec=env VAR=1 /opt/PhotoCraft/photocraft %F`) would be started with the photo only: + // skip it and look further + .filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().to_lowercase().contains("photocraft"))) + .and_then(|p| on_path(&p)) }) }; - if let Some(p) = on_path.or_else(from_entry) { + let appimage = || { + let home = env("HOME")?; + let dirs = [Path::new(&home).join("Applications"), Path::new(&home).join(".local/bin")]; + // both folders, so the newest version wins whichever it is in + let mut found: Vec = dirs + .iter() + .filter_map(|d| std::fs::read_dir(d).ok()) + .flat_map(|rd| rd.flatten().map(|e| e.path()).filter(|p| is_photocraft_appimage(p) && p.is_file()).collect::>()) + .collect(); + // the newest version by name (1.10 after 1.9) + found.sort_by_key(|p| version_key(&p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default())); + found.pop() + }; + if let Some(p) = on_path(Path::new("photocraft")).or_else(from_entry).or_else(appimage) { return Some(Launcher::Program(p)); } let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success()); @@ -205,10 +236,44 @@ fn desktop_exec(entry: &str) -> Option { Some(rest) => rest.split('"').next()?, None => exec.split_whitespace().next()?, }; - // a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a - // freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on - // (`Path::is_absolute` wants a drive letter on Windows, where the tests run too) - program.starts_with('/').then(|| PathBuf::from(program)) + // a path, or a bare name (`photocraft %F`) the caller looks for on the PATH + (!program.is_empty()).then(|| PathBuf::from(program)) +} + +/// A program from a desktop entry or the PATH: a path as it is (if it is a file), a bare name +/// looked for in `dirs`. A leading `/` counts as absolute on every platform (desktop entries are +/// a POSIX format; `Path::is_absolute` wants a drive letter on Windows, where the tests run too). +fn resolve_program(name: &Path, dirs: &[PathBuf]) -> Option { + if name.is_absolute() || name.starts_with("/") { + return Some(name.to_path_buf()).filter(|p| p.is_file()); + } + dirs.iter().map(|d| d.join(name)).find(|p| p.is_file()) +} + +/// A sort key that orders the digit runs in a name by value (`1.9` before `1.10`). +fn version_key(name: &str) -> Vec<(u64, String)> { + let mut out = Vec::new(); + let mut rest = name; + while !rest.is_empty() { + let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + let (num, tail) = rest.split_at(digits); + let text_len = tail.find(|c: char| c.is_ascii_digit()).unwrap_or(tail.len()); + let (text, next) = tail.split_at(text_len); + out.push((num.parse().unwrap_or(u64::MAX), text.to_string())); + rest = next; + } + out +} + +/// An AppImage of PhotoCraft by its file name (`PhotoCraft-1.2-x86_64.AppImage`, any case). +fn is_photocraft_appimage(p: &Path) -> bool { + let name = p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + name.contains("photocraft") && name.ends_with(".appimage") +} + +/// The first application bundle in `mdfind` output (one path per line). +fn mdfind_app(output: &str) -> Option { + output.lines().map(str::trim).find(|l| l.ends_with(".app")).map(PathBuf::from) } #[cfg(test)] @@ -275,13 +340,59 @@ mod tests { let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n"; assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage"))); assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft"))); - assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup"); + assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), Some(PathBuf::from("photocraft")), "a bare name, for a PATH lookup"); assert_eq!(desktop_exec("[Desktop Entry]\n"), None); let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string())); assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications")); assert!(dirs.contains(&PathBuf::from("/usr/share/applications"))); } + #[test] + fn a_bare_exec_name_is_found_on_the_path() { + let a = std::env::temp_dir().join(format!("lc-editors-a-{}", std::process::id())); + let b = std::env::temp_dir().join(format!("lc-editors-b-{}", std::process::id())); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(b.join("photocraft"), b"").unwrap(); + let dirs = vec![a.clone(), b.clone()]; + assert_eq!(resolve_program(Path::new("photocraft"), &dirs), Some(b.join("photocraft"))); + assert_eq!(resolve_program(Path::new("nothere"), &dirs), None); + let abs = b.join("photocraft"); + assert_eq!(resolve_program(&abs, &[]), Some(abs.clone()), "a path needs no PATH"); + assert_eq!(resolve_program(&b.join("missing"), &dirs), None); + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn appimage_versions_sort_numerically() { + let mut names = ["photocraft-1.10-x86_64.appimage", "photocraft-1.9-x86_64.appimage", "photocraft-1.2-x86_64.appimage"]; + names.sort_by_key(|n| version_key(n)); + assert_eq!(names[2], "photocraft-1.10-x86_64.appimage"); + assert_eq!(names[0], "photocraft-1.2-x86_64.appimage"); + } + + #[test] + fn a_failed_open_is_an_error() { + assert!(open_result(true, "").is_ok()); + assert!(open_result(false, "Unable to find application\n").is_err()); + assert!(open_result(false, "").is_err(), "even when it says nothing"); + } + + #[test] + fn odd_detection_input_does_not_panic() { + for text in ["", "Exec=", "Exec=\"", "Exec=\"\"", "\u{0}\u{fffd}", "Exec= %F", "[Desktop Entry]\nExec=\u{e9}\u{301}"] { + let _ = desktop_exec(text); + let _ = mdfind_app(text); + let _ = reg_value(text); + } + for p in ["", "/", ".AppImage", "\u{e9}.appimage", "a/../b"] { + let _ = is_photocraft_appimage(Path::new(p)); + } + let _ = desktop_entry_dirs(|_| None); + let _ = windows_candidates(None, Some("\"".into()), |_| None); + } + #[test] fn other_editors_are_used_as_named() { assert_eq!(launcher("").unwrap(), Launcher::Default); diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 1fe570599..574ee8acb 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -3,8 +3,10 @@ //! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control ] [files or folders…]` //! //! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else -//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else -//! `~/Pictures/LightCraft Library`; a new library starts with the +//! `$LIGHTCRAFT_LIBRARY`, else — with Settings ▸ General ▸ At launch set to *Ask me*, or Alt/Option +//! held as the window appears — the one chosen in a list of the recent libraries, else the library +//! last opened, else `~/Pictures/LightCraft Library` +//! ([`lightcraft_ui_egui::panels::library_problem::launch_plan`]); 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). A library @@ -39,7 +41,7 @@ mod window_gpu; use dialog_filter::FileDialogExt as _; use lightcraft_engine::Session; -use lightcraft_ui_egui::panels::library_problem::LibraryProblem; +use lightcraft_ui_egui::panels::library_problem::{Launch, LibraryProblem}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; /// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name. @@ -363,7 +365,7 @@ struct PrefsWriter { impl PrefsWriter { fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { - self.save_ui(&app.ui, app.library_problem.is_some()) + self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing)) } /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be @@ -727,9 +729,13 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> } /// The library session with the folders face models (`/models`) and slideshow templates -/// (`/slideshow-templates`) are kept in. -fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { - let (mut s, problem) = open_library_session(in_memory, dir, seed_demo); +/// (`/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet: +/// the window lists the recent ones. +fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option) { + let (mut s, problem) = match launch { + Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), + Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), + }; s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); // Control requests are answered on the UI thread: Add to Apple Photos must not wait there @@ -778,7 +784,9 @@ lightcraft — photo library + raw developer USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported) OPTIONS: - --library DIR open (or create) this library (default: ~/Pictures/LightCraft Library; env LIGHTCRAFT_LIBRARY) + --library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or + a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held, + else ~/Pictures/LightCraft Library) --no-demo don't seed a new library with the procedural demo photos --memory throwaway in-memory session (alias --demo); nothing is saved --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; @@ -909,15 +917,15 @@ fn main() -> eframe::Result { if let Some(ui) = &prefs { lightcraft_ui_egui::i18n::set_language(ui.language); } - // --library, else the library last opened from Settings, else the default location - let library_dir = library_dir.or_else(|| { - prefs - .as_ref() - .map(|u| u.settings.library_path.clone()) - .filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none()) - .map(Into::into) - }); - let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); + // --library, else LIGHTCRAFT_LIBRARY, else the list of recent libraries if Settings say Ask + // me, else the library last opened from Settings, else the default location + let launch = lightcraft_ui_egui::panels::library_problem::launch_plan( + in_memory, + library_dir, + std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()).map(Into::into), + prefs.as_ref().map(|u| &u.settings), + lightcraft_engine::library::default_dir(), + ); // GPU compute: off if the preference says so, or if the last launch died creating the device // (issue #136) — before anything can create it let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory)); @@ -970,7 +978,7 @@ fn main() -> eframe::Result { // the window's renderer is up (eframe calls this after creating it) win_files_app.started(window_renderer); came_up_app.store(true, std::sync::atomic::Ordering::Relaxed); - let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); + let (mut session, mut problem) = open_session(in_memory, &launch, seed_demo && files.is_empty()); // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) // and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) @@ -981,6 +989,11 @@ fn main() -> eframe::Result { if let Some(ui) = prefs { app.ui = ui; } + // the recent libraries to choose from (issue #1290), checked on a worker once shown + if launch == Launch::Ask { + problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + } + app.alt_asks_for_library = matches!(launch, Launch::Open { alt_asks: true, .. }); lightcraft_ui_egui::i18n::set_language(app.ui.language); app.integrated_titlebar = cfg!(target_os = "macos"); app.notices.extend(prefs_warning); @@ -1154,12 +1167,40 @@ mod tests { /// adds to Apple Photos on a worker (headless sessions wait instead). #[test] fn the_desktop_session_adds_to_apple_photos_in_the_background() { - let (s, problem) = open_session(true, None, false); + let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false); assert!(problem.is_none()); assert!(s.apple_photos_background); assert!(!lightcraft_engine::Session::new().apple_photos_background); } + /// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo + /// photos) before the user chooses, and the settings are still saved while the list shows (a + /// removed entry is kept), unlike the failed-library window's temporary session. + #[test] + fn asking_at_launch_opens_nothing_and_still_saves_the_settings() { + let (s, problem) = open_session(false, &Launch::Ask, true); + assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty()); + assert!(s.apple_photos_background, "the same desktop session as an opened one"); + + let d = dir("writer-ask"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(s, Services::default()); + app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + app.ui.settings.recent_libraries.remove(0); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + // the failed-library window still writes nothing + app.library_problem = Some(LibraryProblem::new("/gone/b", "not there")); + app.ui.settings.recent_libraries.clear(); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + let _ = std::fs::remove_dir_all(&d); + } + /// Issue #103: a damaged ui.json (which holds the library location) is kept aside and /// reported, not silently replaced by the defaults. #[test] diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 14debc498..e9323a210 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -16,7 +16,9 @@ use std::collections::{HashMap, HashSet}; use std::sync::mpsc::{Receiver, channel}; use lightcraft_ui_egui::LightcraftApp; -use lightcraft_ui_egui::menubar::{MenuNode, menu_bar}; +use lightcraft_ui_egui::menubar::{ + MenuNode, menu_bar, native_item_presentation, native_menu_delivers as menu_delivers, native_menu_structure as structure_of, +}; use muda::accelerator::{Accelerator, Code, Modifiers}; use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use serde_json::Value; @@ -57,8 +59,10 @@ struct Item { } impl Item { + /// The row's text as installed: `label` is already what the row shows (translated, with a + /// borrowed key's hint, `native_item_presentation`). fn native_label(&self) -> String { - lightcraft_ui_egui::menubar::display_item_label(&self.cmd, &self.params, &self.label).replace('&', "&&") + self.label.replace('&', "&&") } /// Puts `want` on the native item. muda's `set_accelerator(None)` does nothing on macOS (the @@ -276,13 +280,6 @@ fn yields_to_text(sc: &str) -> bool { !sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc) } -/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function -/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run -/// those even though their menu item shows the key. -fn menu_delivers(sc: &str) -> bool { - sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::().is_ok())) -} - /// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`. fn owned_by_menu<'a>(installed: impl Iterator, text_focus: bool) -> HashSet { installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect() @@ -293,34 +290,6 @@ fn label_text(s: &str) -> String { lightcraft_ui_egui::i18n::tr(s).replace('&', "&&") } -fn structure_of(bar: &[(String, Vec)]) -> String { - fn walk(n: &[MenuNode], out: &mut String) { - for x in n { - match x { - MenuNode::Item { id, params, checked, shortcut, .. } => { - out.push_str(&MenuNode::key(id, params)); - out.push(if checked.is_some() { 'c' } else { 'i' }); - out.push_str(shortcut.as_deref().unwrap_or("")); - } - MenuNode::Separator => out.push('-'), - MenuNode::Submenu { label, children } => { - out.push('['); - out.push_str(label); - walk(children, out); - out.push(']'); - } - } - out.push(';'); - } - } - let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code()); - for (t, items) in bar { - s.push_str(t); - walk(items, &mut s); - } - s -} - /// Drop a trailing separator (left behind when an item moved to the app menu). fn tidy_separators(mut nodes: Vec) -> Vec { while matches!(nodes.last(), Some(MenuNode::Separator)) { @@ -353,9 +322,10 @@ impl NativeMenu { m } + /// Recreate native handles and shortcut ownership from the current shared menu model. fn rebuild(&mut self, app: &mut LightcraftApp) { let bar = menu_bar(app); - self.structure = structure_of(&bar); + self.structure = structure_of(&bar, &app.ui.settings.keymap); self.menu = Menu::new(); self.items.clear(); self.app_items.clear(); @@ -389,7 +359,7 @@ impl NativeMenu { shortcut: Some(shortcut.to_string()), accel, current: accel, - label: text.to_string(), + label: lightcraft_ui_egui::menubar::display_item_label(cmd, &Value::Null, text).to_string(), enabled: true, checked: None, }); @@ -414,7 +384,7 @@ impl NativeMenu { let nodes = tidy_separators(nodes); // Swapping an item in the Help menu leaves the old row in place and adds a second one // (measured on macOS 27 with muda 0.21): the Help menu is not addressed row for row. - self.append_nodes(&sub, &nodes, title != "Help"); + self.append_nodes(&sub, &nodes, &app.ui.settings.keymap, title != "Help"); if title == "File" { // ⌘W, the system's own item let _ = sub.append_items(&[ @@ -441,8 +411,9 @@ impl NativeMenu { self.publish_shortcuts(app); } + /// Install translated rows with their effective accelerators, preserving hint-only keys. /// `swappable`: whether its items may be replaced to drop their key (see `Item::swappable`). - fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], swappable: bool) { + fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], keymap: &lightcraft_ui_egui::shortcuts::Keymap, swappable: bool) { for n in nodes { match n { MenuNode::Separator => { @@ -450,7 +421,7 @@ impl NativeMenu { } MenuNode::Submenu { label, children } => { let s = Submenu::new(label_text(label), true); - self.append_nodes(&s, children, swappable); + self.append_nodes(&s, children, keymap, swappable); let _ = sub.append(&s); } MenuNode::Item { id, params, label, shortcut, enabled, checked } => { @@ -458,26 +429,16 @@ impl NativeMenu { if self.items.contains_key(&key) { continue; // muda ids must be unique } - let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator); + let (native_label, native_key) = native_item_presentation(keymap, id, params, label, shortcut.as_deref()); + let accel = native_key.filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator); let handle = match checked { Some(c) => { - let it = CheckMenuItem::with_id( - key.clone(), - lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"), - *enabled, - *c, - accel, - ); + let it = CheckMenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, *c, accel); let _ = sub.append(&it); Handle::Check(it) } None => { - let it = MenuItem::with_id( - key.clone(), - lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"), - *enabled, - accel, - ); + let it = MenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, accel); let _ = sub.append(&it); Handle::Plain(it) } @@ -493,7 +454,7 @@ impl NativeMenu { shortcut: shortcut.clone(), accel, current: accel, - label: label.clone(), + label: native_label, enabled: *enabled, checked: *checked, }, @@ -533,23 +494,25 @@ impl NativeMenu { } } let bar = menu_bar(app); - if structure_of(&bar) != self.structure { + if structure_of(&bar, &app.ui.settings.keymap) != self.structure { // (no early return: a rebuild installs every accelerator and resets the state they were synced // to, so the sync below runs in this same frame and no key reaches the menu in between) self.rebuild(app); } - fn walk(nodes: &[MenuNode], items: &mut HashMap) { + /// Synchronize live labels and states; accelerator ownership changes already triggered a rebuild. + fn walk(nodes: &[MenuNode], items: &mut HashMap, keymap: &lightcraft_ui_egui::shortcuts::Keymap) { for n in nodes { match n { - MenuNode::Submenu { children, .. } => walk(children, items), - MenuNode::Item { id, params, label, enabled, checked, .. } => { + MenuNode::Submenu { children, .. } => walk(children, items, keymap), + MenuNode::Item { id, params, label, shortcut, enabled, checked } => { let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue }; - if it.label != *label { + let (native_label, _) = native_item_presentation(keymap, id, params, label, shortcut.as_deref()); + if it.label != native_label { match &it.handle { - 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('&', "&&")), + Handle::Plain(h) => h.set_text(native_label.replace('&', "&&")), + Handle::Check(h) => h.set_text(native_label.replace('&', "&&")), } - it.label = label.clone(); + it.label = native_label; } if it.enabled != *enabled { match &it.handle { @@ -570,7 +533,7 @@ impl NativeMenu { } } } - walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items); + walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items, &app.ui.settings.keymap); let focus = ctx.egui_wants_keyboard_input(); let capturing = app.recording_shortcut.is_some(); @@ -610,7 +573,7 @@ mod tests { let mut seen = std::collections::BTreeSet::new(); for language in Locale::ALL { set_language(*language); - assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key"); + assert!(seen.insert(structure_of(&bar, &app.ui.settings.keymap)), "{language:?} shares another language's menu structure key"); } set_language(Locale::En); } diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 1f84ec477..fefa65a51 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -93,7 +93,6 @@ pub fn choose(files: &Files) -> Chosen { let compiled = wgpu::Instance::enabled_backend_features(); let os = std::env::consts::OS; let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true); - let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name())); let mut problems = Vec::new(); let chosen = if cfg!(windows) { pick_probed(&order, &mut problems) @@ -104,18 +103,7 @@ pub fn choose(files: &Files) -> Chosen { Some((WindowRenderer::Wgpu(backend::window_backends()), None)) }; let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None)); - if !problems.is_empty() { - notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string()); - } - let notice = notice.map(|n| { - format!( - "{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \ - (see docs/gpu-pipeline.md → Troubleshooting).", - which.name(), - app = "LightCraft", - var = "LIGHTCRAFT_GPU_BACKEND", - ) - }); + let notice = user_notice(failed, forced, &problems, which); log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::>()); let mut wgpu_options = base_options(); let renderer = match (which, existing) { @@ -134,6 +122,25 @@ pub fn choose(files: &Files) -> Chosen { Chosen { renderer, wgpu_options, which, notice } } +/// What to tell the user about the window's renderer. A renderer that failed its probe is skipped +/// silently (it is logged, and the one that worked is remembered): the app works, so a pop-up with +/// wgpu's error only alarms (#1624). Shown: the last start died, or the forced renderer failed. +fn user_notice(failed: Option, forced: Option, problems: &[String], which: WindowRenderer) -> Option { + let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name())); + if forced.is_some() && !problems.is_empty() { + notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string()); + } + notice.map(|n| { + format!( + "{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \ + (see docs/gpu-pipeline.md → Troubleshooting).", + which.name(), + app = "LightCraft", + var = "LIGHTCRAFT_GPU_BACKEND", + ) + }) +} + /// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471). pub fn base_options() -> WgpuConfiguration { let mut c = WgpuConfiguration::default(); @@ -269,6 +276,20 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// #1624: a renderer skipped by the probe (DX12 → Vulkan) is no pop-up; a died start or a + /// failed forced renderer still is. + #[test] + fn probe_fallback_is_silent() { + let (dx, vk) = (WindowRenderer::Wgpu(Backends::DX12), WindowRenderer::Wgpu(Backends::VULKAN)); + let problems = vec!["The dx12 renderer failed (Validation Error).".to_string()]; + assert_eq!(user_notice(None, None, &problems, vk), None); + assert_eq!(user_notice(None, None, &[], vk), None); + let forced = user_notice(None, Some(dx), &problems, vk).unwrap(); + assert!(forced.starts_with("The dx12 renderer failed") && forced.contains("now draws its window with vulkan"), "{forced}"); + let died = user_notice(Some(dx), None, &problems, vk).unwrap(); + assert!(died.contains("last time") && !died.contains("Validation Error"), "{died}"); + } + /// The crash report: a pipeline that fails validation must come back as an error, never as /// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU). #[test] diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 30eea5654..3bb745457 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -8,6 +8,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | Path | Asset | Creator | Source | Licence | Added | Modifications | |---|---|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | egui icon subset and monochrome fallback | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | 2026-10-24 (notice audit) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full fallback. Registered by production default font definitions; dependency assets unmodified here, no font file copied into this repository. | | `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none | | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none | @@ -29,7 +30,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `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 | -| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file | +| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular, Noto Sans Georgian Regular, NanumGothic Regular, Noto Sans Symbols Regular, Noto Sans Symbols 2 Regular, Noto Sans Hebrew Regular, Noto Sans Devanagari Regular | The Noto Project Authors (Arabic, Thai, Georgian, Symbols, Symbols 2, Hebrew and Devanagari); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; NHN Corporation and Sandoll Communications (NanumGothic); Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/b076417b5880bd4154bd22f00695b1d28c6bea83/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the default `fonts/manifest.txt` set (all official releases; the opt-in LXGW WenKai Lite face in `fonts/simplified-chinese-manifest.txt` is not embedded); the web build embeds BIZ UDPGothic Regular, Noto Sans Thai Regular and Noto Sans Georgian Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | | `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending | | `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders | diff --git a/contributors/people.toml b/contributors/people.toml index cc71e4cc2..0b7f9ee43 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -61,6 +61,9 @@ sync_github_name = false real_name = "Ege Gursel" sync_github_name = true +[people.valenfys] +sync_github_name = true + [people.mahmatias] display_name = "Márcio Matias de Andrade" sync_github_name = true diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 2b71049ee..2dfebbf90 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -43,19 +43,63 @@ pub fn weekday(iso: &str) -> Option<&'static str> { /// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was. /// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years /// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be -/// anything, it never overflows. +/// anything, it never overflows. Stricter than [`iso_seconds`], which reads what it can for sorting: +/// a time cut short, seconds that aren't digits or anything after the zone is `None` here, not +/// written back as a clean-looking time. pub fn shift_iso(iso: &str, secs: i64) -> Option { + let iso = iso.trim(); + let tail = iso.get(tail_start(iso)?..)?; let t = iso_seconds(iso)?.checked_add(secs)?; let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?); if !(first..=last).contains(&t) { return None; } - let iso = iso.trim(); - // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes - let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or(""); Some(format!("{}{tail}", civil(t))) } +/// Where the part of an ISO time that [`shift_iso`] keeps as it was (fraction, zone) starts, or +/// `None` unless `iso` is a date alone (`2026-01-01`) or a date and `HH:MM`, optional `:SS` with +/// an optional fraction (`.5`), and an optional zone (`Z`, `+02:00`, `-0500`, `+02`), and nothing after. +fn tail_start(iso: &str) -> Option { + let b = iso.as_bytes(); + let digits = |x: &[u8]| !x.is_empty() && x.iter().all(u8::is_ascii_digit); + let digits_at = |a: usize, z: usize| b.get(a..z).is_some_and(digits); + let at = |i: usize, c: u8| b.get(i) == Some(&c); + if !(digits_at(0, 4) && at(4, b'-') && digits_at(5, 7) && at(7, b'-') && digits_at(8, 10)) { + return None; + } + if b.len() == 10 { + return Some(10); + } + if !((at(10, b'T') || at(10, b' ')) && digits_at(11, 13) && at(13, b':') && digits_at(14, 16)) { + return None; + } + // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes + let start = if !at(16, b':') { + 16 + } else if digits_at(17, 19) { + 19 + } else { + return None; + }; + let mut zone = b.get(start..)?; + // a fraction only after the seconds: `10:00.5` is half a minute, not half a second + if let ([b'.', rest @ ..], 19) = (zone, start) { + let n = rest.iter().take_while(|c| c.is_ascii_digit()).count(); + zone = rest.get(n..)?; + if n == 0 { + return None; + } + } + let ok = match zone { + [] | [b'Z'] => true, + [b'+' | b'-', h1, h2, b':', m1, m2] => digits(&[*h1, *h2, *m1, *m2]), + [b'+' | b'-', hm @ ..] => matches!(hm.len(), 2 | 4) && digits(hm), + _ => false, + }; + ok.then_some(start) +} + /// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing /// seconds or time). `None` if it isn't a valid date. pub fn normalize_iso(s: &str) -> Option { @@ -260,6 +304,65 @@ mod tests { assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00")); } + #[test] + fn shift_refuses_a_time_it_cant_fully_read() { + for bad in [ + "2026-01-01T10:0", + "2026-01-01T10", + "2026-01-01T", + "2026-01-01T10:00:xx", + "2026-01-01T10:00:0", + "2026-01-01T10:00:00+", + "2026-01-01T10:00:00+2", + "2026-01-01T10:00:00+02:0", + "2026-01-01T10:00:00.", + "2026-01-01T10:00:00.5.5", + "2026-01-01T10:00.5", + "2026-01-01T10:00:00ZZ", + "2026-01-01T10:00:00 and more", + "2026-01-01T10:00 and more", + "2026-01-01x10:00:00", + "2026/01/01T10:00:00", + ] { + assert_eq!(shift_iso(bad, 3600), None, "{bad} doesn't fully read, so it must not be written back"); + } + // multi-byte characters where the time, the seconds and the zone start never panic + for at in [11, 16, 18, 21] { + let mut s = String::from("2026-01-01T10:00:00+02:00"); + s.replace_range(at..at + 1, "é"); + assert_eq!(shift_iso(&s, 3600), None, "{s}"); + assert_eq!(shift_iso(s.get(..at + 2).unwrap(), 3600), None, "{s}"); + } + } + + #[test] + fn shift_keeps_a_fraction_and_zone_and_reads_back() { + for (from, to) in [ + ("2026-01-01", "2026-01-01T01:00:00"), + (" 2026-01-01T10:00 ", "2026-01-01T11:00:00"), + ("2026-01-01 10:00:30", "2026-01-01T11:00:30"), + ("2026-01-01T10:00:30.5", "2026-01-01T11:00:30.5"), + ("2026-01-01T10:00:30Z", "2026-01-01T11:00:30Z"), + ("2026-01-01T10:00:30+02:00", "2026-01-01T11:00:30+02:00"), + ("2026-01-01T10:00:30-0500", "2026-01-01T11:00:30-0500"), + ("2026-01-01T10:00:30+02", "2026-01-01T11:00:30+02"), + ("2026-01-01T10:00:30.125+02:00", "2026-01-01T11:00:30.125+02:00"), + ] { + assert_eq!(shift_iso(from, 3600).as_deref(), Some(to), "{from}"); + assert_eq!(shift_iso(to, 0).as_deref(), Some(to), "{to} reads back"); + } + } + + /// Sorting, grouping and date rules read with `iso_seconds`, which stays lenient: only the + /// write path refuses what it can't fully read. + #[test] + fn reading_stays_lenient_for_sorting() { + let at_ten = iso_seconds("2026-01-01T10:00:00"); + assert_eq!(iso_seconds("2026-01-01T10:00:00 and more"), at_ten); + assert_eq!(iso_seconds("2026-01-01T10:00:xx"), at_ten); + assert_eq!(iso_seconds("2026-01-01T10:0"), iso_seconds("2026-01-01")); + } + #[test] fn impossible_times_are_rejected_not_rolled_over() { for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] { diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs index aab3b867b..e2d3b312d 100644 --- a/crates/catalog/src/folders.rs +++ b/crates/catalog/src/folders.rs @@ -207,14 +207,31 @@ impl Catalog { /// folder that is no longer there and are dropped (undo restores them). Empty when there is /// nothing to carry or drop. pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec { + self.folder_records_carried(from, to, true) + } + + /// [`Catalog::folder_records_follow`] for a folder located where it already is on disk + /// (`folder.updateLocation`): nothing arrived at `to`, the library only learned that the + /// folder is there. So what it knows about folders at or below `to` (other folders of the + /// library there) stays; a record carried over replaces the one at the same place. + pub fn folder_records_located(&self, from: &str, to: &str) -> Vec { + self.folder_records_carried(from, to, false) + } + + fn folder_records_carried(&self, from: &str, to: &str, clear_destination: bool) -> Vec { let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() }; if root == dest { return Vec::new(); } + // a folder located below itself: what is already at or below `to` belongs to the folders + // of the library there, not to the folder located, so it is neither carried nor cleared + // (the record of `from` itself still lands at `to`, replacing the one there) + let nested = !clear_destination && key_within(&dest, &root); + let carried = |k: &str| key_within(k, &root) && !(nested && key_within(k, &dest)); let moved: Vec<(String, FolderRecord)> = self .folder_records .iter() - .filter(|(k, _)| key_within(k, &root)) + .filter(|(k, _)| carried(k)) .filter_map(|(k, r)| { let rest = folder_rest(k, &root)?; let base = dest.trim_end_matches('/'); @@ -224,7 +241,7 @@ impl Catalog { .collect(); // what was known at the old place goes, and so does what is left at the new one (also // when the moved folder carries nothing: the folder now there is not the one it was about) - let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest)); + let stale = self.folder_records.keys().filter(|k| carried(k) || (clear_destination && key_within(k, &dest))); let mut ops: Vec = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect(); ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) })); ops diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index f40bc81e4..453b68a69 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -36,7 +36,7 @@ pub use folders::{FolderNode, FolderRecord}; pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming}; pub use keywords::KeywordNode; use lightcraft_develop::DevelopSettings; -pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of}; +pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, LocalRecords, folder_of}; pub use lock::{LibraryLock, LockError, LockOwner}; pub use model::*; pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64}; @@ -284,7 +284,8 @@ pub struct Catalog { /// What the library keeps about its folders (folder identity → record), see [`folders`]. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] folder_records: BTreeMap, - /// Increments on every applied op. + /// Increments on every applied op, and when a batch fails and its rollback cannot undo it whole (the catalog + /// changed, so what is keyed by the revision is out of date). #[serde(skip)] pub revision: u64, /// Which photos the latest ops touched ([`Catalog::touched_since`]). @@ -348,6 +349,8 @@ struct Changes { /// outside (a library just loaded takes a range of its own, so that nothing kept from another library is /// taken for this one's): nothing is known of how the catalog got to where it is. seen: u64, + /// A batch that failed could not be rolled back whole: what it changed stays, so what it noted must too. + torn: bool, } impl PartialEq for Changes { @@ -610,13 +613,40 @@ impl Catalog { self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id)) } - /// Apply an op; returns its inverse. On error nothing changes. + /// Apply an op; returns its inverse. On error the catalog is as it was, the revision included, and nothing is + /// remembered of the op in what the latest ops touched. The one exception is a batch whose rollback fails: the + /// catalog did change, so the revision advances and anything may be taken to have changed. pub fn apply(&mut self, op: Op) -> Result { if self.changes.seen != self.revision { // the revision was set from outside: what is remembered is of another past self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() }; } - let inv = self.apply_inner(op)?; + let broad = self.changes.broad; + self.changes.torn = false; + let inv = match self.apply_inner(op) { + Ok(inv) => inv, + Err(e) => { + let made = self.revision.saturating_add(1); + if self.changes.torn { + // (a batch the rollback of which failed changed something and says not what: it makes a + // revision, so what is kept for the one before is dropped, and anything may have changed, as + // after an op of a wider reach) + self.changes.torn = false; + self.changes.broad = made; + self.revision = made; + self.changes.seen = made; + } else { + // (everything the failed op, or the rollback of a batch, noted was made for the revision it + // would have made: nothing earlier has that one. A note the cap already dropped stays + // dropped, which only says less than is known, as the cap does.) + while self.changes.touched.back().is_some_and(|(r, _, _)| *r == made) { + self.changes.touched.pop_back(); + } + self.changes.broad = broad; + } + return Err(e); + } + }; self.revision += 1; self.changes.seen = self.revision; Ok(inv) @@ -962,7 +992,9 @@ impl Catalog { Err(e) => { // roll back what was applied for inv in inverses.into_iter().rev() { - let _ = self.apply_inner(inv); + if self.apply_inner(inv).is_err() { + self.changes.torn = true; + } } return Err(e); } @@ -1179,4 +1211,6 @@ mod tests_repair; #[cfg(test)] mod tests_scale; #[cfg(test)] +mod tests_tally; +#[cfg(test)] mod tests_torn_append; diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 093c22427..20cb3d5b4 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -34,6 +34,15 @@ use serde::Serialize; use crate::{Catalog, Op, Photo, PhotoId, Source}; +/// The Local records of some files ([`Catalog::local_records_of`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LocalRecords { + /// Only a record of having looked: nothing was changed on them and nothing refers to them. + pub untouched: Vec, + /// Holding something of the person's, with their files. + pub kept: Vec<(PhotoId, String)>, +} + /// Default for how long a Local folder may go unbrowsed before its untouched records are /// forgotten (days). pub const DEFAULT_FORGET_DAYS: u32 = 30; @@ -64,7 +73,8 @@ pub fn folder_of(p: &Photo) -> Option { // `/` and `\` are both separators whatever the platform (a catalog can move between them) Source::File { path } => { let key = crate::query::folder_key(path); - key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { dir.to_string() }) + // a drive or share root keeps its trailing `/` in its key (`c:/`, `//srv/share/`) + key.rsplit_once('/').map(|(dir, _)| if dir.is_empty() { "/".to_string() } else { crate::query::folder_key(&format!("{dir}/")) }) } Source::Demo { .. } => None, } @@ -176,6 +186,34 @@ impl Catalog { &self.browsed } + /// The Local records of `files` (paths as stored), for library photos about to point at + /// those files (a folder located where it was browsed, `folder.updateLocation`): a file has one + /// record, so the Local one gives way. Told apart: those that are only a record of having + /// looked (untouched, see the module docs, and nothing refers to them), which can go, and + /// those that hold something of the person's (a rating, an edit, a place in an album), with + /// their files. Records in Recently Deleted are not Local records to this. + pub fn local_records_of(&self, files: &HashSet) -> LocalRecords { + let mut out = LocalRecords::default(); + if files.is_empty() { + return out; + } + let referenced = self.referenced(); + for p in self.photos.values().filter(|p| p.local && !p.deleted) { + let Source::File { path } = &p.source else { continue }; + if !files.contains(path) { + continue; + } + if p.untouched_local() && !referenced.contains(&p.id) { + out.untouched.push(p.id); + } else { + out.kept.push((p.id, path.clone())); + } + } + out.untouched.sort_unstable(); + out.kept.sort_unstable(); + out + } + /// Plan forgetting untouched Local records whose folder wasn't browsed for `days` days (as of /// `now`, ISO 8601). `in_use` keeps records the caller still needs. `days == 0` (never) or an /// unreadable `now` forgets nothing. See the module docs. diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 830857386..ff94268a8 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -240,6 +240,17 @@ fn has_person(p: &Photo, name: &str) -> bool { } impl Filter { + /// The labels that `label` and `labels` let through together (they AND), in `labels`' order; + /// `None` when neither is set, empty when they exclude each other. The chip and the filter + /// bar's buttons show this, so they match what is filtered. + pub fn labels_shown(&self) -> Option> { + match (self.label, self.labels.is_empty()) { + (None, true) => None, + (Some(l), true) => Some(vec![l]), + (l, false) => Some(self.labels.iter().copied().filter(|x| l.is_none_or(|l| l == *x)).collect()), + } + } + /// Human-readable summary of the active rules (smart album tooltips, `album.list`). pub fn describe(&self) -> String { self.describe_in(None) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index bdf4b8c88..2976a2659 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -432,12 +432,79 @@ thread_local! { std::cell::RefCell::new(std::collections::HashMap::new()); } +thread_local! { + /// While [`AlbumPass`] is alive on this thread: the photos of each smart album that was counted whole, with + /// the longest chain of smart albums (itself included) its rules lead through. + static PASS: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// One pass over one catalog state that counts every smart album whole. Each album put in with `remember` +/// answers [`smart_album_holds`] from its photos, not from its rules, until the pass is dropped (it can't outlive +/// the state it was made of: it exists only inside the counting function). +/// +/// Only an album whose rules lead to no loop may be remembered, and only with the height of its chain: its answer +/// then doesn't depend on which albums are being evaluated around it, as long as the chain still fits under +/// [`MAX_ALBUM_DEPTH`] from where it is asked (checked on every use; otherwise the rules are evaluated as before). +pub(crate) struct AlbumPass { + outer: Option>, +} + +impl AlbumPass { + pub(crate) fn begin() -> AlbumPass { + AlbumPass { outer: PASS.with_borrow_mut(|p| p.replace(std::collections::HashMap::new())) } + } + + /// `id` holds exactly `members`, and its rules lead through a chain of `height` smart albums (itself included), + /// none of them in a loop. + pub(crate) fn remember(&self, id: crate::AlbumId, height: usize, members: crate::tally::Members) { + PASS.with_borrow_mut(|p| { + if let Some(p) = p { + p.insert(id, (height, members)); + } + }); + } + + /// Ends the pass, giving back what was remembered. + pub(crate) fn finish(self) -> std::collections::HashMap { + PASS.with_borrow_mut(Option::take).unwrap_or_default().into_iter().map(|(id, (_, members))| (id, members)).collect() + } +} + +impl Drop for AlbumPass { + fn drop(&mut self) { + let outer = self.outer.take(); + PASS.with_borrow_mut(|p| *p = outer); + } +} + +/// What a counting pass knows of smart album `id` for `photo`, when asking now can't give another answer than +/// evaluating its rules would: the chain below it fits under [`MAX_ALBUM_DEPTH`] from the albums being evaluated. +fn pass_answer(id: crate::AlbumId, photo: crate::PhotoId) -> Option { + PASS.with_borrow(|p| { + let (height, members) = p.as_ref()?.get(&id)?; + let depth = EVALUATING.with_borrow(Vec::len); + (depth + height <= MAX_ALBUM_DEPTH).then(|| members.holds(photo)) + }) +} + +#[cfg(test)] +thread_local! { + /// How many times a smart album's rules were worked out on this thread (tests count them). + pub(crate) static EVALUATED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Whether `photo` is in smart album `id`, by `matches` (its rules), remembered for the rest of the /// outermost question and guarded by [`evaluating`] (a loop or a chain too deep holds nothing). pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, matches: impl FnOnce() -> bool) -> bool { + if let Some(known) = pass_answer(id, photo) { + return known; + } if let Some(known) = ANSWERS.with_borrow(|a| a.get(&(id, photo)).copied()) { return known; } + #[cfg(test)] + EVALUATED.with(|n| n.set(n.get() + 1)); // the outermost question forgets the answers when it ends, however it ends struct Forget(bool); impl Drop for Forget { @@ -456,7 +523,7 @@ pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, match } /// How deep smart albums may test smart albums before the innermost counts as holding nothing. -const MAX_ALBUM_DEPTH: usize = 32; +pub(crate) const MAX_ALBUM_DEPTH: usize = 32; /// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id` /// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is diff --git a/crates/catalog/src/tally.rs b/crates/catalog/src/tally.rs index 8f15e5ec4..e8fdc6197 100644 --- a/crates/catalog/src/tally.rs +++ b/crates/catalog/src/tally.rs @@ -8,7 +8,9 @@ use std::collections::{HashMap, HashSet}; -use crate::{AlbumId, Aspects, Catalog, PhotoId}; +use crate::query::Filter; +use crate::rules::{AlbumPass, MAX_ALBUM_DEPTH}; +use crate::{Album, AlbumId, Aspects, Catalog, PhotoId}; /// The most photos an edit may touch for the counts to follow it photo by photo. const MAX_FOLLOWED: usize = 256; @@ -20,7 +22,7 @@ const WORDS_TO_START: usize = 1024; /// The photos a smart album holds. #[derive(Clone, Debug)] -enum Members { +pub(crate) enum Members { /// One bit a photo id, and how many are set. Bits { words: Vec, @@ -41,8 +43,7 @@ impl Members { } } - #[cfg(test)] - fn holds(&self, id: PhotoId) -> bool { + pub(crate) fn holds(&self, id: PhotoId) -> bool { match self { Members::Bits { words, .. } => { usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0) @@ -114,21 +115,46 @@ pub struct AlbumTally { impl AlbumTally { /// Counted from every photo. + /// + /// A smart album that tests another (`album is #14`) reads that album's photos, counted once for the whole + /// pass, instead of working its rules out again for every photo: albums are counted shortest chain first. + /// The photos are kept only until this function returns, so they can't be older than the catalog counted. + /// An album whose rules lead into a loop, or through a chain too deep to be sure of, is counted by its rules + /// as before: the pass ends before the first of them (they sort last), so nothing they evaluate reads it. pub fn of(cat: &Catalog) -> AlbumTally { + let heights = chain_heights(cat); + let height_of = |id: AlbumId| heights.get(&id).copied().flatten().filter(|h| *h <= MAX_ALBUM_DEPTH); + let mut smart: Vec = cat.albums().filter(|a| a.is_smart()).map(|a| a.id).collect(); + smart.sort_by_key(|id| height_of(*id).unwrap_or(usize::MAX)); + let held_by = |id: AlbumId| { + let mut held = Members::none(); + for p in cat.photos().filter(|p| cat.album_contains(id, p)) { + held.set(p.id, true); + } + held + }; + let mut counts = HashMap::new(); let mut members = HashMap::new(); - for album in cat.albums() { - if album.is_smart() { - let mut held = Members::none(); - for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) { - held.set(p.id, true); - } - counts.insert(album.id, held.len()); - members.insert(album.id, held); - } else { - counts.insert(album.id, cat.album_count(album.id)); + let split = smart.partition_point(|id| height_of(*id).is_some()); + let (with_height, without) = smart.split_at(split); + let pass = AlbumPass::begin(); + for id in with_height { + let held = held_by(*id); + counts.insert(*id, held.len()); + if let Some(height) = height_of(*id) { + pass.remember(*id, height, held); } } + members.extend(pass.finish()); + for id in without { + let held = held_by(*id); + counts.insert(*id, held.len()); + members.insert(*id, held); + } + for album in cat.albums().filter(|a| !a.is_smart()) { + counts.insert(album.id, cat.album_count(album.id)); + } AlbumTally { revision: cat.revision, counts, members } } @@ -184,6 +210,57 @@ impl AlbumTally { } } +/// For each smart album, the most smart albums in a chain its rules lead through (itself included), or `None` +/// when a loop is reachable from it (its answer may then depend on which albums are being evaluated around it). +fn chain_heights(cat: &Catalog) -> HashMap> { + struct Frame { + id: AlbumId, + tests: Vec, + next: usize, + tallest: usize, + loops: bool, + } + let tests = |id: AlbumId| -> Vec { + let tested = cat.album(id).and_then(|a| a.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default(); + tested.into_iter().filter(|t| cat.album(*t).is_some_and(Album::is_smart)).collect() + }; + let frame = |id: AlbumId| Frame { id, tests: tests(id), next: 0, tallest: 0, loops: false }; + let mut done: HashMap> = HashMap::new(); + let mut open: HashSet = HashSet::new(); + for root in cat.albums().filter(|a| a.is_smart()).map(|a| a.id) { + if done.contains_key(&root) { + continue; + } + open.insert(root); + let mut stack = vec![frame(root)]; + while let Some(top) = stack.last_mut() { + if let Some(&kid) = top.tests.get(top.next) { + top.next += 1; + match done.get(&kid) { + Some(Some(h)) => top.tallest = top.tallest.max(*h), + Some(None) => top.loops = true, + None if open.contains(&kid) => top.loops = true, + None => { + open.insert(kid); + stack.push(frame(kid)); + } + } + } else if let Some(finished) = stack.pop() { + open.remove(&finished.id); + let height = (!finished.loops).then_some(finished.tallest + 1); + done.insert(finished.id, height); + if let Some(parent) = stack.last_mut() { + match height { + Some(h) => parent.tallest = parent.tallest.max(h), + None => parent.loops = true, + } + } + } + } + } + done +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index f8b72dad8..579175086 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -952,12 +952,13 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() c.apply(Op::SetRating { id: a, rating: 1 }).unwrap(); assert_eq!(c.touched_since(loaded), Some(vec![a])); assert_eq!(c.touched_since(old), None); - // an op that fails changes nothing, and what is remembered never says less than what happened + // an op that fails changes nothing and is not remembered let before = c.revision; assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err()); assert_eq!(c.revision, before); + assert_eq!(c.touched_since(before), Some(vec![])); c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap(); - assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + assert_eq!(c.touched_since(before), Some(vec![a])); assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG))); // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch // with an op that changes more than photos is not @@ -965,8 +966,8 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err()); assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1))); c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap(); - // (the photo the failed batch asked for and never found may be named too: more is said, never less) - assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + // (the failed batch is not remembered: only the batch that worked) + assert_eq!(c.touched_since(before), Some(vec![a])); let before = c.revision; c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap(); assert_eq!(c.touched_since(before), None); @@ -978,3 +979,70 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() assert_eq!(c.touched_since(long_ago), None); assert_eq!(c.touched_since(c.revision - 10), Some(vec![a])); } + +/// An op that fails leaves nothing behind in what the catalog remembers of its ops: no photo it never changed (one +/// that does not even exist), and, for a batch rolled back, none of what its first ops had touched. +#[test] +fn an_op_that_fails_leaves_no_touched_photos_behind() { + let mut c = Catalog::default(); + let add = |c: &mut Catalog, name: &str| { + let id = c.alloc_photo_id(); + c.apply(Op::AddPhoto { + photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")), + }) + .unwrap(); + id + }; + let (a, b) = (add(&mut c, "a.jpg"), add(&mut c, "b.jpg")); + let missing = PhotoId(9_999); + let start = c.revision; + // every op that names a photo, on one that does not exist + for op in [Op::SetRating { id: missing, rating: 1 }, Op::SetFlag { id: missing, flag: Flag::Pick }, Op::RemovePhoto { id: missing }] { + assert!(c.apply(op).is_err()); + assert_eq!(c.revision, start, "a failed op makes no revision"); + assert_eq!(c.touched_since(start), Some(vec![])); + assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); + } + // an op that changes more than photos and fails does not make earlier revisions unknown + assert!(c.apply(Op::RenameAlbum { id: AlbumId(77), name: "x".into() }).is_err()); + assert_eq!(c.touched_since(start), Some(vec![])); + // a batch that fails on its second op names nothing of its first, nor of the rollback + assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: missing, rating: 1 }] }).is_err()); + assert_eq!(c.revision, start); + assert_eq!(c.touched_since(start), Some(vec![])); + assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); + assert_eq!(c.photo(a).map(|p| p.rating), Some(0)); + // a batch that ends in a failed removal of a photo that exists + assert!(c.apply(Op::Batch { ops: vec![Op::RemovePhoto { id: b }, Op::RemovePhoto { id: b }] }).is_err()); + assert!(c.photo(b).is_some()); + assert_eq!(c.touched_since(start), Some(vec![])); + // what succeeded after the failures is remembered, and only that + c.apply(Op::SetRating { id: a, rating: 2 }).unwrap(); + assert_eq!(c.touched_since(start), Some(vec![a])); + assert_eq!(c.aspects_since(start), Some(Aspects::RATING)); +} + +/// A batch the rollback of which fails halfway leaves the catalog changed (here a stack is lost), so what the +/// catalog remembers must not say that nothing changed: anything may have. +#[test] +fn a_batch_that_cannot_be_rolled_back_whole_makes_earlier_changes_unknown() { + let mut c = Catalog::default(); + let mut ids = Vec::new(); + for name in ["a.jpg", "b.jpg"] { + let id = c.alloc_photo_id(); + let photo = Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + ids.push(id); + } + let stack = c.alloc_stack_id(); + c.apply(Op::AddStack { stack: Stack { id: stack, photos: ids.clone(), collapsed: false } }).unwrap(); + let start = c.revision; + // the photo goes, then its stack, then an op fails: putting the stack back needs the photo, which comes later + let failing = + Op::Batch { ops: vec![Op::RemovePhoto { id: ids[0] }, Op::RemoveStack { id: stack }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }; + assert!(c.apply(failing).is_err()); + assert_eq!(c.revision, start + 1, "the catalog changed, so what is kept for the revision before is out of date"); + assert!(c.photo(ids[0]).is_some() && c.stack(stack).is_none(), "the rollback went only part of the way"); + assert_eq!(c.touched_since(start), None, "so nothing is said of what changed"); + assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); +} diff --git a/crates/catalog/src/tests_folder_records.rs b/crates/catalog/src/tests_folder_records.rs index 3ae19c33a..a572db995 100644 --- a/crates/catalog/src/tests_folder_records.rs +++ b/crates/catalog/src/tests_folder_records.rs @@ -161,6 +161,63 @@ fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() { assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple)); } +#[test] +fn a_located_folder_keeps_what_the_library_knows_of_the_folders_already_at_the_destination() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green)); + // other folders of the library at the place it is said to be at: nothing arrived there + label(&mut c, "/pics/home", Some(ColorLabel::Blue)); + label(&mut c, "/pics/home/old", Some(ColorLabel::Yellow)); + let before = c.to_snapshot(); + let ops = c.folder_records_located("/pics/trip", "/pics/home"); + let undo = c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_record("/pics/trip"), None, "what was known at the old place goes"); + assert_eq!(c.folder_record("/pics/trip/day1"), None); + assert_eq!(c.folder_color_label("/pics/home"), Some(ColorLabel::Red), "a record carried over replaces the one at the same place"); + assert_eq!(c.folder_color_label("/pics/home/old"), Some(ColorLabel::Yellow), "a folder of the library there keeps its own"); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +#[test] +fn a_located_folder_with_labels_inside_lands_them_at_the_same_places_below() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green)); + let ops = c.folder_records_located("/pics/trip", "/mnt/nas/trip"); + c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_color_label("/mnt/nas/trip"), Some(ColorLabel::Red)); + assert_eq!(c.folder_color_label("/mnt/nas/trip/day1"), Some(ColorLabel::Green)); +} + +#[test] +fn a_folder_located_below_itself_leaves_the_records_of_the_folders_already_there_in_place() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green)); + // another folder of the library already sits at the place the folder is said to be at + label(&mut c, "/pics/trip/day1/raw", Some(ColorLabel::Blue)); + let before = c.to_snapshot(); + let ops = c.folder_records_located("/pics/trip", "/pics/trip/day1"); + let undo = c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_record("/pics/trip"), None, "what was known at the old place goes"); + assert_eq!(c.folder_color_label("/pics/trip/day1"), Some(ColorLabel::Red), "a record carried over replaces the one at the same place"); + assert_eq!(c.folder_color_label("/pics/trip/day1/raw"), Some(ColorLabel::Blue), "a folder below the destination keeps its own"); + assert_eq!(c.folder_record("/pics/trip/day1/day1"), None, "the record at the destination is not carried below itself"); + assert_eq!(c.folder_record("/pics/trip/day1/day1/raw"), None); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +#[test] +fn a_folder_located_where_it_is_or_without_labels_gives_no_ops() { + let mut c = library(); + assert!(c.folder_records_located("/pics/trip", "/elsewhere/trip").is_empty()); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + assert!(c.folder_records_located("/pics/trip", "/pics/trip").is_empty()); +} + #[test] fn labels_survive_the_journal_without_a_snapshot() { let m = MemStore::new(); diff --git a/crates/catalog/src/tests_local.rs b/crates/catalog/src/tests_local.rs index 4b76dace6..0082763d0 100644 --- a/crates/catalog/src/tests_local.rs +++ b/crates/catalog/src/tests_local.rs @@ -1,5 +1,6 @@ //! Forgetting untouched Local browse records (see [`crate::local`]). +use std::collections::HashSet; use std::sync::Arc; use lightcraft_develop::DevelopSettings; @@ -265,3 +266,51 @@ fn op_photo_ids_cover_nested_ops() { op.photo_ids(&mut |id| ids.push(id.0)); assert_eq!(ids, vec![1, 2, 3, 4]); } + +#[test] +fn drive_and_share_root_records_are_forgotten() { + for (path, folder) in [("C:\\IMG_1.JPG", "C:\\"), ("//srv/share/x.jpg", "//srv/share/")] { + let mut c = Catalog::new(); + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, OLD); + p.local = true; + p.set_local_baseline(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + c.apply(Op::SetBrowsed { folder: folder.into(), at: Some(OLD.into()) }).unwrap(); + assert_eq!(plan(&c, 30).evict, vec![id], "{path}"); + } +} + +fn paths(folder: &str, ns: &[u32]) -> HashSet { + ns.iter().map(|i| format!("/pics/{folder}/IMG_{i}.JPG")).collect() +} + +#[test] +fn local_records_of_files_tell_what_can_give_way_from_what_holds_something() { + let mut c = Catalog::new(); + let untouched = local(&mut c, "trip", 1); + let rated = local(&mut c, "trip", 2); + let in_album = local(&mut c, "trip", 3); + let _elsewhere = local(&mut c, "trip", 4); + c.apply(Op::SetRating { id: rated, rating: 3 }).unwrap(); + let a = c.alloc_album_id(); + let mut album = Album::new(a, "A"); + album.photos.push(in_album); + c.apply(Op::AddAlbum { album }).unwrap(); + + let found = c.local_records_of(&paths("trip", &[1, 2, 3])); + assert_eq!(found.untouched, vec![untouched]); + assert_eq!(found.kept, vec![(rated, "/pics/trip/IMG_2.JPG".to_string()), (in_album, "/pics/trip/IMG_3.JPG".to_string())]); +} + +#[test] +fn local_records_of_files_leave_out_library_photos_and_recently_deleted_ones() { + let mut c = Catalog::new(); + let gone = local(&mut c, "trip", 1); + c.apply(Op::SetDeleted { id: gone, deleted: true }).unwrap(); + let id = c.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: "/pics/trip/IMG_2.JPG".into() }, "IMG_2.JPG", "JPEG", 60, 40, OLD); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + assert_eq!(c.local_records_of(&paths("trip", &[1, 2])), LocalRecords::default()); + assert_eq!(c.local_records_of(&HashSet::new()), LocalRecords::default()); +} diff --git a/crates/catalog/src/tests_tally.rs b/crates/catalog/src/tests_tally.rs new file mode 100644 index 000000000..4953a8bd1 --- /dev/null +++ b/crates/catalog/src/tests_tally.rs @@ -0,0 +1,172 @@ +//! A whole count of every album (`AlbumTally::of`) keeps each smart album's photos for the pass, so an album testing +//! another doesn't work its rules out again. The counts must be the ones each album gets counted alone. + +use serde_json::json; + +use super::*; +use crate::tally::AlbumTally; + +fn smart(id: u64, rules: serde_json::Value) -> Album { + let rule_set = Some(serde_json::from_value(rules).unwrap()); + Album { smart: Some(Box::new(Filter { rule_set, ..Default::default() })), ..Album::new(AlbumId(id), format!("Smart {id}")) } +} + +fn photos(n: u64) -> Vec { + (0..n) + .map(|i| { + // folders with capitals, accents and a Windows path, so the text rules see every spelling + let dir = ["Asian/Japanese", "Été/Ünï", "C:\\Users\\Me\\Pictures", "plain", "[SITES]/x"][(i % 5) as usize]; + let mut p = Photo::new( + PhotoId(i + 1), + Source::File { path: format!("/Volumes/share/photos/{dir}/name{}/IMG_{i:04}.jpg", i % 9) }, + &format!("IMG_{i:04}.jpg"), + "JPEG", + 6000, + 4000, + "2026-01-01T00:00:00", + ); + p.rating = (i % 6) as u8; + p.flag = [Flag::None, Flag::Pick, Flag::Reject][(i % 3) as usize]; + p.label = (i % 4 == 0).then_some(ColorLabel::Red); + p.meta.keywords = (0..i % 4).map(|k| format!("kw{k}")).collect(); + p.deleted = i % 37 == 0; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect() +} + +fn is(album: u64) -> serde_json::Value { + json!({"field": "album", "op": "is", "value": album}) +} + +fn is_not(album: u64) -> serde_json::Value { + json!({"field": "album", "op": "isNot", "value": album}) +} + +fn path(op: &str, value: &str) -> serde_json::Value { + json!({"field": "filePath", "op": op, "value": value}) +} + +/// Every album's count in `c`, each album counted alone (no pass, rules worked out per photo). +fn counted_alone(c: &Catalog) -> Vec<(AlbumId, usize)> { + let mut v: Vec<_> = c.albums().map(|a| (a.id, c.album_count(a.id))).collect(); + v.sort_unstable(); + v +} + +fn counted_together(c: &Catalog) -> Vec<(AlbumId, usize)> { + let tally = AlbumTally::of(c); + let mut v: Vec<_> = tally.counts().iter().map(|(id, n)| (*id, *n)).collect(); + v.sort_unstable(); + v +} + +#[test] +fn a_whole_count_equals_each_album_counted_alone() { + let mut c = Catalog::new(); + let mut ops = photos(240); + let mut plain = Album::new(AlbumId(1), "Picks"); + plain.photos = (0..240).step_by(4).map(|i| PhotoId(i + 1)).collect(); + let folder = Album { folder: true, ..Album::new(AlbumId(2), "Trips") }; + let inside = Album { parent: Some(AlbumId(2)), photos: (0..240).step_by(3).map(|i| PhotoId(i + 1)).collect(), ..Album::new(AlbumId(3), "In") }; + ops.extend([plain, folder, inside].map(|album| Op::AddAlbum { album })); + // the inner albums: #2-like, #14-like, then albums chained on it, the other way round, in groups and in different orders + ops.push(Op::AddAlbum { + album: smart( + 10, + json!({"match": "any", "rules": [{"field": "flag", "op": "is", "value": "reject"}, {"field": "label", "op": "is", "value": "red"}]}), + ), + }); + ops.push(Op::AddAlbum { + album: smart(11, json!({"match": "all", "rules": [is_not(10), path("notContains", "Été"), path("notContains", "plain")]})), + }); + for k in 0..9u64 { + let tail = path("contains", &format!("NAME{k}")); + let rules = match k % 3 { + 0 => json!({"match": "all", "rules": [is(11), tail]}), + 1 => json!({"match": "all", "rules": [json!({"field": "keywordCount", "op": "lte", "value": 2}), is(11), tail]}), + _ => json!({"match": "all", "rules": [tail, is(11)]}), + }; + ops.push(Op::AddAlbum { album: smart(12 + k, rules) }); + } + ops.push(Op::AddAlbum { + album: smart( + 21, + json!({"match": "all", "rules": [{"group": {"match": "any", "rules": [is(11), is(1)]}}, {"field": "rating", "op": "gte", "value": 2}]}), + ), + }); + ops.push(Op::AddAlbum { album: smart(22, json!({"match": "all", "rules": [is_not(21), is(3)]})) }); + ops.push(Op::AddAlbum { album: smart(23, json!({"match": "any", "rules": [is(2), is(999), is(1)]})) }); + // a loop saved anyway (30 ↔ 31), albums that test it from outside (32, 33), and one that tests both worlds + ops.push(Op::AddAlbum { album: smart(30, json!({"rules": []})) }); + ops.push(Op::AddAlbum { album: smart(31, json!({"rules": [is_not(30)]})) }); + ops.push(Op::AddAlbum { album: smart(32, json!({"rules": [is(31), {"field": "rating", "op": "gte", "value": 1}]})) }); + ops.push(Op::AddAlbum { album: smart(33, json!({"rules": [is_not(32), is(11)]})) }); + ops.push(Op::AddAlbum { album: smart(34, json!({"rules": [is(34)]})) }); + // a chain deeper than the limit on how deep albums may test albums + ops.push(Op::AddAlbum { album: smart(100, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) }); + for k in 101..=140u64 { + ops.push(Op::AddAlbum { album: smart(k, json!({"rules": [is(k - 1)]})) }); + } + ops.push(Op::AddAlbum { album: smart(141, json!({"rules": [is(140), is_not(11)]})) }); + // an album past the depth limit that also tests a memoized album directly: where the shared album is read from the + // pass near the top, the chain below must still find it answered as it always did (200 ← 231 … 201, height 32) + ops.push(Op::AddAlbum { album: smart(200, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) }); + for k in 201..=230u64 { + ops.push(Op::AddAlbum { album: smart(k, json!({"rules": [is(k + 1)]})) }); + } + ops.push(Op::AddAlbum { album: smart(231, json!({"rules": [is(200)]})) }); + ops.push(Op::AddAlbum { album: smart(250, json!({"match": "all", "rules": [is(200), is(201)]})) }); + c.apply(Op::Batch { ops }).unwrap(); + c.apply(Op::SetAlbumRules { + id: AlbumId(30), + rules: Box::new(Filter { rule_set: Some(serde_json::from_value(json!({"rules": [is_not(31)]})).unwrap()), ..Default::default() }), + }) + .unwrap(); + + let alone = counted_alone(&c); + let count = |id: u64| alone.iter().find(|(a, _)| a.0 == id).map(|(_, n)| *n); + assert!(count(11) > Some(0) && count(131) > Some(0), "{alone:?}"); + assert_eq!(count(132), Some(0), "the chain is too deep from here: holds nothing, as it did"); + assert!(count(250) > Some(0), "200 is answered near the top, so the chain finds it answered"); + assert_eq!(counted_together(&c), alone); + // the pass leaves nothing behind: the same answers whichever album asks first, and again after an edit + c.apply(Op::SetAlbumRules { + id: AlbumId(10), + rules: Box::new(Filter { + rule_set: Some(serde_json::from_value(json!({"rules": [{"field": "rating", "op": "is", "value": 5}]})).unwrap()), + ..Default::default() + }), + }) + .unwrap(); + let after = counted_alone(&c); + assert_ne!(after, alone); + assert_eq!(counted_together(&c), after); +} + +/// The reason for the pass: with an album tested by many, its rules are worked out once per photo, not once per +/// photo for each album that tests it. +#[test] +fn an_album_tested_by_many_is_worked_out_once_a_photo() { + let mut c = Catalog::new(); + let mut ops: Vec = (0..60u64) + .map(|i| { + let mut p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 0 }, "a.jpg", "JPEG", 10, 10, "2026-01-01T00:00:00"); + p.rating = (i % 6) as u8; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect(); + ops.push(Op::AddAlbum { album: smart(1, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) }); + for k in 0..10u64 { + ops.push(Op::AddAlbum { album: smart(2 + k, json!({"rules": [is(1), {"field": "rating", "op": "lte", "value": k % 6}]})) }); + } + c.apply(Op::Batch { ops }).unwrap(); + let alone = counted_alone(&c); + crate::rules::EVALUATED.with(|n| n.set(0)); + let tally = AlbumTally::of(&c); + let worked = crate::rules::EVALUATED.with(std::cell::Cell::get); + assert_eq!(worked, 11 * 60, "each of the 11 smart albums asks each of the 60 photos once"); + let mut together: Vec<_> = tally.counts().iter().map(|(id, n)| (*id, *n)).collect(); + together.sort_unstable(); + assert_eq!(together, alone); +} diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index b8de12bdd..d8b0241a1 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -17,6 +17,8 @@ pub(crate) struct Markers { pub sof: u8, /// Number of components in the first scan (< `components` for non-interleaved sequential files). pub first_scan_components: u8, + /// Number of APP1 segments: capped before the allocating decoders walk them again. + pub app1: u32, pub exif: Option>, /// Offset of the EXIF TIFF header within the file. pub exif_offset: usize, @@ -83,6 +85,7 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { m.components = seg[5]; } 0xE1 => { + m.app1 = m.app1.saturating_add(1); if seg.starts_with(b"Exif\0\0") && m.exif.is_none() { m.exif = Some(seg[6..].to_vec()); m.exif_offset = start + 6; @@ -121,6 +124,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { decode_with_fallback(bytes, opts, None) } +const MAX_APP1: u32 = 128; + /// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The /// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame /// type or component count, or when its markers don't run to an end-of-image marker after the @@ -178,6 +183,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: return Err(Error::Malformed(F, "no frame header".into())); } check_size(F, m.width as u64, m.height as u64, opts)?; + // The allocating decoders below parse the markers again, all of them: the count is capped so + // their quadratic/unbounded handling of APP1 metadata stays linear in file size. + if m.app1 > MAX_APP1 { + return Err(Error::Malformed(F, "too many APP1 markers".into())); + } let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2))); // zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK, // 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling) @@ -185,9 +195,9 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: let lossless = m.sof == 0xC3; let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless { - decode_jpeg_decoder(bytes, &m, scale_to)? + decode_jpeg_decoder(bytes, &m, scale_to, opts)? } else { - decode_zune(bytes, &m)? + decode_zune(bytes, &m, opts)? }; // A raw container may carry the colour declaration of an otherwise untagged preview. // Apply it before linearization/resizing; existing JPEG metadata always wins. @@ -196,7 +206,7 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: finish(F, raw, meta, (m.width, m.height), opts) } -fn decode_zune(bytes: &[u8], m: &Markers) -> Result { +fn decode_zune(bytes: &[u8], m: &Markers, budget: &DecodeOptions) -> Result { use zune_core::bytestream::ZCursor; use zune_core::colorspace::ColorSpace; use zune_core::options::DecoderOptions; @@ -207,6 +217,11 @@ fn decode_zune(bytes: &[u8], m: &Markers) -> Result { ColorSpace::RGB }); let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), opts); + // zune's `decode` allocates straight from the frame header it parses, with its own caps + // set in us: check that header against the same limits the other decoders honour first. + d.decode_headers().map_err(|e| Error::Malformed(F, e.to_string()))?; + let (zw, zh) = d.info().map(|i| (i.width as u64, i.height as u64)).ok_or_else(|| Error::Malformed(F, "no info".into()))?; + check_size(F, zw, zh, budget)?; let px = d.decode().map_err(|e| Error::Malformed(F, e.to_string()))?; let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?; let (w, h) = (info.width as usize, info.height as usize); @@ -217,10 +232,15 @@ fn decode_zune(bytes: &[u8], m: &Markers) -> Result { Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf: Buf::U8(px), bit_depth: 8 }) } -fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) -> Result { +fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>, budget: &DecodeOptions) -> Result { use jpeg_decoder::PixelFormat; let mut d = jpeg_decoder::Decoder::new(bytes); d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?; + // Its frame header is what a decode allocates from (progressive coefficient buffers follow the + // unscaled MCU grid): check it against the same limits the other decoders honour, before any + // DCT scale is requested. + let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?; + check_size(F, info.width as u64, info.height as u64, budget)?; // `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the // output to the top-left of each plane. Those decode at full size; `finish` scales after. if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) { @@ -369,11 +389,18 @@ pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec MAX_APP1 { + return Err(Error::Malformed(F, "too many APP1 markers".into())); + } if !matches!(m.components, 1 | 3) || m.precision != 8 { return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into())); } let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; - let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? }; + let raw = if non_interleaved { + decode_jpeg_decoder(bytes, &m, None, &DecodeOptions::default())? + } else { + decode_zune(bytes, &m, &DecodeOptions::default())? + }; let ch = raw.model.channels(); match raw.buf { Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)), @@ -513,7 +540,7 @@ mod tests { fn lossless_grayscale_samples_are_native_endian_and_left_justified() { let bytes = lossless_jpeg(1, 8, 12); let m = parse_markers(&bytes).unwrap(); - let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None, &DecodeOptions::default()).unwrap(); let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified"); assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16)); @@ -525,7 +552,7 @@ mod tests { fn lossless_high_precision_rgb_is_16_bit() { let bytes = lossless_jpeg(3, 2, 12); let m = parse_markers(&bytes).unwrap(); - let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None, &DecodeOptions::default()).unwrap(); let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; assert_eq!(v, &vec![2048u16 << 4; 6]); assert_eq!(raw.model, Model::Rgb); @@ -536,7 +563,7 @@ mod tests { fn lossless_high_precision_cmyk_is_16_bit() { let bytes = lossless_jpeg(4, 2, 12); let m = parse_markers(&bytes).unwrap(); - let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None, &DecodeOptions::default()).unwrap(); let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; assert_eq!(v, &vec![2048u16 << 4; 8]); assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16)); @@ -551,4 +578,77 @@ mod tests { assert_eq!((d.width, d.height), (8, 1)); assert_eq!(d.bit_depth, 8); } + + /// Rewrites an encoded file's frame-header dimensions: same tables and scan, a hostile + /// annotated size. + fn with_dims(bytes: &[u8], w: u16, h: u16) -> Vec { + let mut out = bytes.to_vec(); + let mut p = 2usize; + while p + 1 < out.len() { + if out[p] != 0xFF { + p += 1; + continue; + } + match out[p + 1] { + 0xC0..=0xCF if out[p + 1] != 0xC4 && out[p + 1] != 0xC8 && out[p + 1] != 0xCC => { + out[p + 5..p + 7].copy_from_slice(&h.to_be_bytes()); + out[p + 7..p + 9].copy_from_slice(&w.to_be_bytes()); + return out; + } + 0x01 | 0xD0..=0xD9 => p += 2, + 0xFF => p += 1, + _ => { + let Some(l) = out.get(p + 2..p + 4) else { return out }; + p += 2 + usize::from(u16::from_be_bytes([l[0], l[1]])); + } + } + } + out + } + + /// A file claiming 65535×65535 must be refused for size before zune-jpeg allocates from + /// its frame header (4 GB+ of samples from roughly five hundred bytes of tables). + #[test] + fn zune_refuses_its_own_frame_header_over_the_budget() { + use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let pixels = vec![128u8; 32 * 24]; + let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 1, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let hostile = with_dims(&jpeg, 65535, 65535); + assert_ne!(hostile, jpeg); + match crate::decode(&hostile, DecodeOptions::default()) { + Err(Error::TooLarge(w, h)) => assert_eq!((w, h), (65535, 65535)), + other => panic!("unexpected: {other:?}"), + } + } + + /// The jpeg-decoder branch (a 4-component file) is checked the same way: its own + /// `read_info` result is what a full-size decode allocates from. + #[test] + fn jpeg_decoder_refuses_its_own_frame_header_over_the_budget() { + use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let pixels = vec![128u8; 32 * 24 * 4]; + let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 4, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let hostile = with_dims(&jpeg, 65535, 65535); + match crate::decode(&hostile, DecodeOptions::default()) { + Err(Error::TooLarge(w, h)) => assert_eq!((w, h), (65535, 65535)), + other => panic!("unexpected: {other:?}"), + } + } + + /// Extended XMP is reassembled from every APP1 fragment: an unbounded count is quadratic + /// memory in a marker walk, so the count is capped. + #[test] + fn an_app1_flood_is_refused_before_the_decoders_rewalk_it() { + let mut b = vec![0xFF, 0xD8]; + // a 1-component SOF0: 8-bit, 2×2, component (1, 0x11, Q0) + b.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x0B, 8, 0, 2, 0, 2, 1, 0x01, 0x11, 0x00]); + for _ in 0..200 { + b.extend_from_slice(&[0xFF, 0xE1, 0x00, 0x02]); + } + b.extend_from_slice(&[0xFF, 0xD9]); + match crate::decode(&b, DecodeOptions::default()) { + Err(Error::Malformed(F, m)) => assert!(m.contains("APP1"), "{m}"), + other => panic!("unexpected: {other:?}"), + } + } } diff --git a/crates/codecs/src/other.rs b/crates/codecs/src/other.rs index d5659de90..ebba2eaed 100644 --- a/crates/codecs/src/other.rs +++ b/crates/codecs/src/other.rs @@ -5,6 +5,9 @@ use crate::{DecodeOptions, Decoded, Error, Format, Result}; use image::{DynamicImage, ImageDecoder}; pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result { + if format == Format::Gif { + gated_first_frame(bytes, opts)?; + } let err = |e: image::ImageError| Error::Malformed(format, e.to_string()); let cursor = std::io::Cursor::new(bytes); let (img, icc) = match format { @@ -36,3 +39,83 @@ pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Resu let raw = Raw { width: w as usize, height: h as usize, model, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 }; finish(format, raw, Meta { icc, ..Default::default() }, (w, h), opts) } + +/// Bounds a GIF's first image descriptor: the decoder composites frames into the logical screen +/// (bounded by [`check_size`] on the canvas) but allocates its own frame buffers at the frame's +/// declared size, which a small file can inflate far past the canvas. The canvas check cannot +/// see that size, so it is read from the block headers here. +fn gated_first_frame(bytes: &[u8], opts: &DecodeOptions) -> Result<()> { + // The constants of the header grammar: signature, logical screen descriptor (with its global + // colour table), extension blocks (0x21, skipped by their sub-block chains) and image + // descriptors (0x2C). + const SCREEN: usize = 13; + if bytes.len() < SCREEN { + return Ok(()); // too broken for a frame; the decoder reports the damage itself + } + let gct_entries = 1usize << (u32::from(bytes[10] & 0x07) + 1); // at most 256 + let gct_bytes = if (bytes[10] & 0x80) != 0 { gct_entries * 3 } else { 0 }; + let mut p = SCREEN.saturating_add(gct_bytes); + while let Some(&kind) = bytes.get(p) { + match kind { + 0x2C => { + // Image descriptor: two LE coordinates then two LE edges, after the separator. + let end = p.checked_add(10).filter(|&e| e <= bytes.len()); + let Some(d) = end.and_then(|e| bytes.get(p + 1..e)) else { + break; // cut short mid-descriptor: the decoder reports that + }; + let (w, h) = (u16::from_le_bytes([d[4], d[5]]) as u64, u16::from_le_bytes([d[6], d[7]]) as u64); + return check_size(Format::Gif, w, h, opts); + } + 0x21 => { + // Extension: a label byte, then sub-blocks ended by a zero length. + p = p.saturating_add(2); + loop { + let Some(&len) = bytes.get(p) else { + return Ok(()); // ran past the data: decode reports the truncation + }; + p = p.saturating_add(1); + if len == 0 { + break; + } + p = p.saturating_add(len as usize); + } + } + _ => p = p.saturating_add(1), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::{DecodeOptions, Error}; + + fn gif(blocks: &[u8]) -> Vec { + let mut b = b"GIF89a".to_vec(); + // logical screen: 2×2, no global colour table, background 0, aspect 0 + b.extend_from_slice(&[2, 0, 2, 0, 0x00, 0, 0]); + b.extend_from_slice(blocks); + b + } + + /// `image`'s GIF decoder allocates its frame buffer at the image descriptor's declared + /// size, which the canvas bound ([`crate::decode`]'s `max_pixels` on the logical screen) + /// does not cover. A 20-byte file used to buy a 4 GB allocation before the gate. + #[test] + fn a_first_frame_over_the_bound_is_refused_without_allocating_it() { + let bytes = gif(&[0x2C, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0x00]); + match crate::decode(&bytes, DecodeOptions::default()) { + Err(Error::TooLarge(w, h)) => assert_eq!((w, h), (65535, 65535)), + other => panic!("unexpected: {other:?}"), + } + } + + /// A frame within the bound still reaches the decoder (which reports the truncated data); + /// the gate may only refuse on size. + #[test] + fn a_first_frame_within_the_bound_is_not_refused() { + let bytes = gif(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0x00]); + let r = crate::decode(&bytes, DecodeOptions::default()); + assert!(matches!(r, Err(Error::Malformed(..)) | Err(Error::TooLarge(..))), "{r:?}"); + } +} diff --git a/crates/codecs/src/png_codec.rs b/crates/codecs/src/png_codec.rs index 098f1cfaa..6837d178e 100644 --- a/crates/codecs/src/png_codec.rs +++ b/crates/codecs/src/png_codec.rs @@ -7,6 +7,10 @@ use lightcraft_color::{Mat3, RgbSpace, Xy}; const F: Format = Format::Png; +/// Expansion cap for a compressed text chunk's payload: real XMP documents are a few hundred +/// kilobytes, so anything past this is hostile input, not metadata. +const MAX_TEXT_CHUNK: usize = 4 << 20; + fn err(e: impl std::fmt::Display) -> Error { Error::Malformed(F, e.to_string()) } @@ -42,11 +46,18 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { }; let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth }; + // png's `get_text()` inflates a compressed chunk with no bound of its own: a tiny + // iTXt can claim an XMP document of arbitrary size. Expand through the bounded path + // instead, and drop any XMP bigger than the cap. let xmp = info .utf8_text .iter() .find(|t| t.keyword == "XML:com.adobe.xmp") - .and_then(|t| t.get_text().ok()) + .and_then(|t| { + let mut t = t.clone(); + t.decompress_text_with_limit(MAX_TEXT_CHUNK).ok()?; + t.get_text().ok() + }) .or_else(|| info.uncompressed_latin1_text.iter().find(|t| t.keyword == "XML:com.adobe.xmp").map(|t| t.text.clone())); let exif = info.exif_metadata.as_ref().map(|e| { let e = e.to_vec(); @@ -168,3 +179,55 @@ fn rgb_to_xyz_d50_checked(space: &RgbSpace) -> Option { } Some(crate::space::rgb_to_xyz_d50(space)) } + +#[cfg(test)] +mod tests { + use crate::DecodeOptions; + + /// A compressed text chunk is expanded by `png` through our own cap: an XMP document + /// bigger than it is dropped, not inflated. Compressing an 8 MiB string is a few + /// kilobytes — the reverse of what the unbounded `get_text()` path allowed. + #[test] + fn compressed_xmp_over_the_cap_is_dropped() { + let out = png_with_compressed_xmp(&"x".repeat(8 << 20)); + let d = crate::decode(&out, DecodeOptions::default()).unwrap(); + assert!(d.xmp.is_none()); + assert_eq!((d.width, d.height), (1, 1)); + } + + /// XMP within the cap is still read, compressed or not. + #[test] + fn compressed_xmp_under_the_cap_is_read() { + let out = png_with_compressed_xmp(&"".repeat(100)); + let d = crate::decode(&out, DecodeOptions::default()).unwrap(); + assert!(d.xmp.as_deref().is_some_and(|x| x.contains("xmp/"))); + } + + /// A little-endian PNG with one compressed iTXt chunk carrying `payload` as XMP. + fn png_with_compressed_xmp(payload: &str) -> Vec<u8> { + use png::text_metadata::{EncodableTextChunk, ITXtChunk}; + let mut base = Vec::new(); + { + let mut enc = png::Encoder::new(&mut base, 1, 1); + enc.set_color(png::ColorType::Grayscale); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0x7F]).unwrap(); + w.finish().unwrap(); + } + // `ITXtChunk::encode` writes the complete chunk (length, type, payload, CRC), with + // the text compressed. + let mut chunk = Vec::new(); + let mut xmp = ITXtChunk::new("XML:com.adobe.xmp", payload); + xmp.compressed = true; + xmp.compress_text().unwrap(); + xmp.encode(&mut chunk).unwrap(); + // Text chunks after IDAT land in `info` once the stream is finished: insert ours + // before the trailing IEND (a zero-length chunk is exactly 12 bytes). + let split = base.len() - 12; + let mut out = base[..split].to_vec(); + out.extend_from_slice(&chunk); + out.extend_from_slice(&base[split..]); + out + } +} diff --git a/crates/engine/src/availability.rs b/crates/engine/src/availability.rs index 5976a3b2e..3cf5ce19c 100644 --- a/crates/engine/src/availability.rs +++ b/crates/engine/src/availability.rs @@ -7,6 +7,16 @@ //! 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. +//! +//! The same worker can check a list of files, such as every library photo's, now and then +//! ([`Availability::sweep`]), one at a time and only while nothing is asked. That is for the +//! Missing Photos count ([`Availability::is_gone`]): one stream of checks on a share, not one per +//! thing that wants to know. The app starts the sweeps; nothing here does. +//! +//! A sweep of a large library on a share takes tens of minutes, and its `stat`s compete with the +//! reads of an import or a scan on the same share. So the worker starts no sweep check while the +//! caller says files are busy ([`Availability::sweep_yields_to`]); what is asked still gets +//! answered (within [`YIELD_POLL`]), and the sweep goes on where it stopped when the work ends. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; @@ -14,9 +24,16 @@ 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. +/// Called (from the worker) when an answer changed, e.g. to repaint the UI (a sweep's first look +/// at a file that is there changes no answer anyone sees, so it wakes nobody). pub type Waker = Arc<dyn Fn() + Send + Sync>; +/// Whether file work that a sweep should not compete with is going on. +pub type Busy = Arc<dyn Fn() -> bool + Send + Sync>; + +/// How often a sweep that is giving way looks whether the work has ended. +const YIELD_POLL: Duration = Duration::from_millis(250); + /// 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); @@ -31,9 +48,17 @@ struct State { known: HashMap<String, (bool, std::time::Instant)>, queue: VecDeque<String>, queued: HashSet<String>, + /// What a sweep has left to check: after the queue, whatever it holds. + sweep: VecDeque<String>, + /// The files found not there (and not found there since). + gone: HashSet<String>, worker: bool, - /// Bumped whenever an answer changes (callers recompute what depends on it). - generation: u64, + /// Bumped when the sweep under way is dropped for another one ([`Availability::restart_sweeps`]). + sweep_epoch: u64, + /// Whether file work a sweep gives way to is going on. + busy: Option<Busy>, + /// Bumped whenever a file is found gone, or found back. + gone_generation: u64, } fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> { @@ -66,7 +91,8 @@ impl Availability { self.probe = probe; let mut st = lock(&self.state); st.known.clear(); - st.generation += 1; + st.gone.clear(); + st.gone_generation += 1; } /// Answer from the cache and check on a worker thread from now on; `wake` runs when an @@ -78,6 +104,14 @@ impl Availability { self.background = Some(wake); } + /// The sweep starts no check while `busy` says so (the caller's import, scan or Local read in + /// flight); asked files are answered all the same, and the sweep resumes where it was left when + /// `busy` turns false. `busy` should read state that a task's guard owns (the activity stack), so + /// a task that never ends cleanly can't hold the sweep for good. + pub fn sweep_yields_to(&self, busy: Busy) { + lock(&self.state).busy = Some(busy); + } + /// The existence check itself (blocking: for worker threads, e.g. the Rename preview's). pub fn probe(&self) -> ExistsProbe { self.probe.clone() @@ -99,12 +133,7 @@ impl Availability { 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); - } + self.start(st, wake); } cached.map(|c| c.0) } @@ -114,9 +143,10 @@ impl Availability { self.exists(path) == Some(false) } - /// Changes whenever a cached answer changes. - pub fn generation(&self) -> u64 { - lock(&self.state).generation + /// Changes whenever a file is found gone or found back: what [`Availability::is_offline`] + /// says changed for some file. (A file found there at its first check changes nothing.) + pub fn gone_generation(&self) -> u64 { + lock(&self.state).gone_generation } /// Paths waiting for the worker. @@ -124,37 +154,123 @@ impl Availability { lock(&self.state).queue.len() } + /// Check `paths` too, in the background and after whatever is asked meanwhile, skipping those + /// checked less than [`RECHECK`] ago. One sweep at a time: `false` (and nothing queued) while + /// one is under way, or in synchronous mode. A sweep's answers wake only when a file is found + /// gone or back. + pub fn sweep(&self, paths: Vec<String>) -> bool { + self.sweep_checked(None, paths) + } + + fn sweep_checked(&self, epoch: Option<u64>, paths: Vec<String>) -> bool { + let Some(wake) = &self.background else { return false }; + let mut st = lock(&self.state); + if !st.sweep.is_empty() || epoch.is_some_and(|e| e != st.sweep_epoch) { + return false; + } + st.sweep = paths.into(); + self.start(st, wake); + true + } + + /// Drop what the sweep under way has left (another library was opened: its files are not the + /// ones to check now) and say which sweeps may start from here ([`Availability::sweep_in`]): a + /// list of files being made for the old one is then refused. + pub fn restart_sweeps(&self) -> u64 { + let mut st = lock(&self.state); + st.sweep.clear(); + st.sweep_epoch += 1; + st.sweep_epoch + } + + /// [`Availability::sweep`], unless [`Availability::restart_sweeps`] was called since it + /// returned `epoch`. + pub fn sweep_in(&self, epoch: u64, paths: Vec<String>) -> bool { + self.sweep_checked(Some(epoch), paths) + } + + /// Whether the file at `path` was found gone (and not found there since): `false` for a file + /// not checked yet. + pub fn is_gone(&self, path: &str) -> bool { + lock(&self.state).gone.contains(path) + } + + /// How many files are found gone. + pub fn gone_len(&self) -> usize { + lock(&self.state).gone.len() + } + + /// What the sweep under way has left to check (0: none under way, though its last file may still + /// be in flight). + pub fn sweeping(&self) -> usize { + lock(&self.state).sweep.len() + } + + /// Start the worker, unless it runs. + fn start(&self, mut st: MutexGuard<'_, State>, wake: &Waker) { + if !st.worker { + st.worker = true; + drop(st); + self.spawn_worker(wake.clone()); + } + } + 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 = { + // what was asked first; a sweep's next file when nothing is (unless it is asked + // about too, or was checked a moment ago) + let (path, asked) = { let mut st = lock(&state); - let Some(p) = st.queue.pop_front() else { - st.worker = false; - return; - }; - p + let giving_way = if st.queue.is_empty() && !st.sweep.is_empty() { st.busy.clone() } else { None }; + if let Some(busy) = giving_way { + drop(st); + if busy() { + // (the worker stays: a question asked meanwhile is answered within YIELD_POLL) + std::thread::sleep(YIELD_POLL); + continue; + } + st = lock(&state); + } + match st.queue.pop_front() { + Some(p) => (p, true), + None => loop { + let Some(p) = st.sweep.pop_front() else { + st.worker = false; + return; + }; + let fresh = st.known.get(&p).is_some_and(|(_, at)| at.elapsed() < RECHECK); + if !fresh && !st.queued.contains(&p) { + break (p, false); + } + }, + } }; let ok = probe(&path); - let changed = { + let wakes = { let mut st = lock(&state); - st.queued.remove(&path); + if asked { + st.queued.remove(&path); + } + let gone_changed = if ok { st.gone.remove(&path) } else { st.gone.insert(path.clone()) }; 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; + if gone_changed { + st.gone_generation += 1; } - changed + (asked && changed) || gone_changed }; - if changed { + if wakes { wake(); } } }); if started.is_err() { - // try again on the next question - lock(&self.state).worker = false; + // try again on the next question; a sweep nobody works on would refuse every new one + let mut st = lock(&self.state); + st.worker = false; + st.sweep.clear(); } } } @@ -210,6 +326,209 @@ mod tests { 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); + assert!(a.gone_generation() >= 2); + } + + fn wait_until(what: &str, done: impl Fn() -> bool) { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while !done() { + assert!(std::time::Instant::now() < deadline, "timed out waiting: {what}"); + std::thread::sleep(Duration::from_millis(2)); + } + } + + fn held<T>(m: &Mutex<T>) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// A background `Availability` whose probe logs what it checks; files named `gone…` are not there. + fn logged() -> (Availability, Arc<Mutex<Vec<String>>>, Arc<AtomicUsize>) { + let mut a = Availability::default(); + let log = Arc::new(Mutex::new(Vec::new())); + let l = log.clone(); + a.set_probe(Arc::new(move |p| { + held(&l).push(p.to_string()); + !p.starts_with("gone") + })); + let woke = Arc::new(AtomicUsize::new(0)); + let w = woke.clone(); + a.run_in_background(Arc::new(move || { + w.fetch_add(1, Ordering::SeqCst); + })); + (a, log, woke) + } + + #[test] + fn the_files_a_sweep_finds_gone_are_counted() { + let (a, _, _) = logged(); + assert!(a.sweep(vec!["here".into(), "gone 1".into(), "also here".into(), "gone 2".into()])); + wait_until("the sweep", || a.sweeping() == 0 && a.pending() == 0); + wait_until("the answers", || a.gone_len() == 2); + assert!(a.is_gone("gone 1") && a.is_gone("gone 2") && !a.is_gone("here") && !a.is_gone("also here")); + } + + #[test] + fn files_found_there_at_first_look_change_nothing_for_missing_photos() { + let (a, _, woke) = logged(); + let before = a.gone_generation(); + a.sweep(vec!["a".into(), "b".into(), "c".into()]); + wait_until("the answers", || ["a", "b", "c"].iter().all(|p| lock(&a.state).known.contains_key(*p))); + assert_eq!(a.gone_generation(), before, "nothing went missing"); + assert_eq!(woke.load(Ordering::SeqCst), 0, "a sweep finding files there wakes nobody"); + a.sweep(vec!["gone".into()]); + wait_until("the gone file", || a.gone_generation() != before); + wait_until("the wake", || woke.load(Ordering::SeqCst) == 1); + } + + #[test] + fn what_is_asked_is_checked_before_the_rest_of_a_sweep_and_one_sweep_runs_at_a_time() { + let mut a = Availability::default(); + let log = Arc::new(Mutex::new(Vec::<String>::new())); + // the first file of the sweep holds the worker until the test has asked about another + let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + let (l, g) = (log.clone(), gate.clone()); + a.set_probe(Arc::new(move |p| { + if p == "sweep 0" { + let (open, cv) = &*g; + let mut open = held(open); + while !*open { + open = cv.wait(open).unwrap_or_else(PoisonError::into_inner); + } + } + held(&l).push(p.to_string()); + true + })); + a.run_in_background(Arc::new(|| {})); + assert!(a.sweep((0..50).map(|i| format!("sweep {i}")).collect())); + assert!(!a.sweep(vec!["another".into()]), "a sweep under way: the next one waits for its turn"); + assert_eq!(a.exists("asked"), None, "not known yet"); + *held(&gate.0) = true; + gate.1.notify_all(); + wait_until("the log", || held(&log).len() == 51); + let log = held(&log); + // (at most the file the worker was already checking comes first) + assert!(matches!(log.iter().position(|p| p == "asked"), Some(0 | 1)), "asked next, ahead of the sweep: {log:?}"); + assert!(!log.iter().any(|p| p == "another")); + } + + #[test] + fn a_new_sweep_replaces_the_one_under_way_and_a_list_made_for_the_old_one_is_refused() { + let mut a = Availability::default(); + let log = Arc::new(Mutex::new(Vec::<String>::new())); + // the old sweep's first file holds the worker until the new one is in place + let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + let (l, g) = (log.clone(), gate.clone()); + a.set_probe(Arc::new(move |p| { + if p == "old 0" { + let (open, cv) = &*g; + let mut open = held(open); + while !*open { + open = cv.wait(open).unwrap_or_else(PoisonError::into_inner); + } + } + held(&l).push(p.to_string()); + true + })); + a.run_in_background(Arc::new(|| {})); + let old = a.restart_sweeps(); + assert!(a.sweep_in(old, (0..20).map(|i| format!("old {i}")).collect())); + wait_until("the worker takes the first file", || a.sweeping() == 19); + // another library is opened: what is left of the old sweep goes, and a list still being made for it too + let new = a.restart_sweeps(); + assert_eq!(a.sweeping(), 0); + assert!(!a.sweep_in(old, vec!["old late".into()]), "a list made for the old library is refused"); + assert!(a.sweep_in(new, (0..3).map(|i| format!("new {i}")).collect())); + *held(&gate.0) = true; + gate.1.notify_all(); + wait_until("the new sweep", || held(&log).len() == 4); + assert_eq!(*held(&log), ["old 0", "new 0", "new 1", "new 2"]); + } + + #[test] + fn files_found_gone_are_told_apart_from_files_never_checked() { + let (a, _, _) = logged(); + assert!(!a.is_gone("gone 1") && a.gone_len() == 0); + assert!(a.sweep(vec!["gone 1".into(), "here".into()])); + wait_until("the sweep", || a.sweeping() == 0 && a.pending() == 0 && a.gone_len() == 1); + assert!(a.is_gone("gone 1") && !a.is_gone("here") && !a.is_gone("never asked")); + } + + fn busy_flag(a: &Availability) -> Arc<std::sync::atomic::AtomicBool> { + let busy = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let b = busy.clone(); + a.sweep_yields_to(Arc::new(move || b.load(Ordering::SeqCst))); + busy + } + + #[test] + fn a_sweep_gives_way_to_file_work_answers_what_is_asked_and_resumes_after() { + let (a, log, _) = logged(); + let busy = busy_flag(&a); + assert!(a.sweep((0..20).map(|i| format!("sweep {i}")).collect())); + std::thread::sleep(YIELD_POLL * 3); + assert!(held(&log).is_empty(), "no sweep check while files are busy"); + assert_eq!(a.sweeping(), 20, "nothing of the sweep is lost"); + assert_eq!(a.exists("asked"), None); + wait_until("the answer", || a.exists("asked") == Some(true)); + assert_eq!(*held(&log), ["asked"], "what is asked is answered, the sweep still waits"); + busy.store(false, Ordering::SeqCst); + wait_until("the sweep", || a.sweeping() == 0); + wait_until("the log", || held(&log).len() == 21); + // it went on from its first file, not from scratch + assert_eq!(held(&log).iter().filter(|p| *p == "sweep 0").count(), 1); + } + + #[test] + fn a_sweep_stopped_part_way_goes_on_from_there() { + let mut a = Availability::default(); + let log = Arc::new(Mutex::new(Vec::<String>::new())); + let l = log.clone(); + // the worker is held inside the fourth check until the sweep is told to give way + let hold = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let h = hold.clone(); + a.set_probe(Arc::new(move |p| { + while p == "sweep 3" && h.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(1)); + } + held(&l).push(p.to_string()); + true + })); + a.run_in_background(Arc::new(|| {})); + let busy = busy_flag(&a); + busy.store(false, Ordering::SeqCst); + assert!(a.sweep((0..100).map(|i| format!("sweep {i}")).collect())); + wait_until("a few checks", || held(&log).len() >= 3); + busy.store(true, Ordering::SeqCst); + hold.store(false, Ordering::SeqCst); + std::thread::sleep(YIELD_POLL * 2); + let stopped = held(&log).len(); + assert_eq!(stopped, 4, "stopped part way, after the check it was in"); + std::thread::sleep(YIELD_POLL * 2); + assert_eq!(held(&log).len(), stopped, "paused"); + assert_eq!(a.sweeping(), 100 - stopped, "what is left is kept"); + busy.store(false, Ordering::SeqCst); + wait_until("the sweep", || a.sweeping() == 0 && held(&log).len() == 100); + let mut all = held(&log).clone(); + all.sort(); + all.dedup(); + assert_eq!(all.len(), 100, "every file checked once, none again from the start"); + } + + #[test] + fn a_sweep_that_yields_to_nothing_runs_as_before() { + let (a, log, _) = logged(); + assert!(a.sweep(vec!["a".into(), "b".into()])); + wait_until("the sweep", || held(&log).len() == 2); + } + + #[test] + fn a_sweep_skips_files_checked_a_moment_ago() { + let (a, log, _) = logged(); + assert_eq!(a.exists("a"), None); + wait_until("the answer", || a.exists("a").is_some()); + a.sweep(vec!["a".into(), "b".into()]); + wait_until("b", || held(&log).iter().any(|p| p == "b")); + wait_until("the sweep", || a.sweeping() == 0); + assert_eq!(held(&log).iter().filter(|p| *p == "a").count(), 1, "`a` was checked a moment ago"); } } diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index f6600c6b1..383ed890d 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -9,10 +9,12 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, cmd}; use crate::{Result, Session}; -/// Seconds between shots of the same burst at most. -const BURST_GAP: i64 = 10; -/// Signature similarity for "the same scene". -const SAME: f32 = 0.93; +/// Seconds between shots of the same burst at most (default of `burstGap`). +const BURST_GAP: f64 = 10.0; +/// Signature similarity for "the same scene" (default of `similarity`). +const SAME: f64 = 0.93; +/// The longest `burstGap` accepted: a day. +const MAX_GAP: f64 = 86_400.0; fn secs(iso: &str) -> Option<i64> { let d = lightcraft_meta::DateTime::parse_iso(iso)?; @@ -26,6 +28,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> { let ids: Vec<PhotoId> = ids.into_iter().filter(|id| seen.insert(*id)).collect(); let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); + let (gap, same) = burst_params(p)?; // measure (thumbnail-level sources: fast, and enough for focus at 512 px) let mut rows: Vec<(PhotoId, Option<i64>, f32, f32, [f32; 64])> = Vec::new(); let mut failed = Vec::new(); @@ -45,8 +48,8 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> { let mut next = 1u32; for i in 1..rows.len() { let (a, b) = (&rows[i - 1], &rows[i]); - let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP); - if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME { + let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y.saturating_sub(x)).unsigned_abs() as f64 <= gap); + if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= same { let g = *group_of[i - 1].get_or_insert_with(|| { next += 1; next - 1 @@ -86,6 +89,22 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed})) } +/// `burstGap` (seconds, 0..=a day) and `similarity` (0..=1) of `photo.analyze`, refused before +/// anything is measured when they are not numbers in range. +fn burst_params(p: &Value) -> Result<(f64, f32)> { + const C: &str = "photo.analyze"; + let num = |key: &str, d: f64, max: f64| -> Result<f64> { + match p.get(key) { + None | Some(Value::Null) => Ok(d), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (0.0..=max).contains(&x) => Ok(x), + _ => Err(super::bad(C, format!("`{key}` must be a number from 0 to {max}"))), + }, + } + }; + Ok((num("burstGap", BURST_GAP, MAX_GAP)?, num("similarity", SAME, 1.0)? as f32)) +} + /// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by /// content), most similar first; the view is filtered to them. fn find_similar(s: &mut Session, p: &Value) -> Result<Value> { @@ -139,7 +158,7 @@ pub fn specs() -> Vec<CommandSpec> { "Assisted Culling", [], None, - "{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}", + "{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool, burstGap?: seconds 0..86400 (10), similarity?: 0..1 (0.93)} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots (signature similarity at least `similarity`) taken at most `burstGap` seconds apart and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}", always, analyze ), diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 0f3b8827a..07dcbe0ec 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -451,7 +451,10 @@ pub fn specs() -> Vec<CommandSpec> { let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable}); match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) { Some(path) => { - std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + s.check_write_target(path).map_err(|e| bad("keyword.export", e))?; + // A folder that isn't there is an error, not created. + lightcraft_catalog::safe_file::write_atomic(std::path::Path::new(path), file.text.as_bytes()) + .map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; r["path"] = json!(path); } None => r["text"] = json!(file.text), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 334d9c92d..9d11cc5b3 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -7,6 +7,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param}; use crate::{LibrarySource, Result, Selection, Session}; +/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it). +/// Its callers end an open edit (a slider drag) before their own change, as moving to the next +/// photo does, so it is its own undo step and comes first. fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { let at = before.iter().position(|id| Some(*id) == s.selection.active); let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); @@ -201,7 +204,6 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V if n > 0 { s.commit(label, Op::Batch { ops })?; } - advance_if(s, p); Ok(json!({"changed": n})) } @@ -220,10 +222,36 @@ fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> F if n > 0 { s.commit(label, Op::Batch { ops })?; } - advance_if(s, p); Ok(json!({"changed": n})) } +/// Rate, flag or label (`edit`), then `advance`. When the edit took the active photo out of the view (the filter bar, +/// Picks or a smart album shows it no more), culling goes on from its place, as after a delete: the photo after it is +/// active, or the one before it at the end (#2312), not the view's first photo. That photo is the next one, so +/// `advance` doesn't step past it. Photos named by `ids` / `id` leave the selection to the caller: Compare and Survey +/// keep showing the photo they rate. +fn cull(s: &mut Session, p: &Value, edit: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> { + let named = [p.get("ids"), p.get("id")].into_iter().any(|t| t.is_some_and(|t| !t.is_null())); + // the view as it was, then an open drag ends first, as its own undo step (the selection may + // move on; the drag itself can take the photo out of the view) + let before = (!named).then(|| s.visible_shared().1); + if !named { + s.end_interaction()?; + } + let active = s.selection.active; + let v = edit(s)?; + if let Some(before) = before + && active.is_some_and(|a| before.contains(&a) && !s.visible().contains(&a)) + { + select_after_deletion(s, &before); + s.active_mask = None; + s.active_spot = None; + } else { + advance_if(s, p); + } + Ok(v) +} + fn advance_if(s: &mut Session, p: &Value) { if bool_or(p, "advance", false) { let _ = step(s, 1); @@ -371,6 +399,9 @@ pub fn specs() -> Vec<CommandSpec> { if s.catalog.album(a).is_none_or(|a| a.folder) { return Err(bad("library.source", "no such album")); } + // one album at a time: `to_filter` makes the album shown replace the filter's, and the + // filter bar has no album chip, so a left-over one would narrow the next source unseen + s.filter.album = None; LibrarySource::Album(a) } "libraryFolder" => { @@ -764,40 +795,66 @@ pub fn specs() -> Vec<CommandSpec> { |s, p| { step(s, -row_step(p)) } ), // ---- rating / flags / labels - cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| { - let r = super::f64_req(p, "rating", "photo.rate")? as i64; - if !(0..=5).contains(&r) { - return Err(bad("photo.rate", "rating must be 0..5")); + cmd!( + "photo.rate", + "Set Rating", + ["Photo", "Set Rating"], + None, + "{rating: 0..5, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + let r = super::index_req(p, "rating", "photo.rate") + .ok() + .filter(|r| *r <= 5) + .ok_or_else(|| bad("photo.rate", "rating must be a whole number 0..5"))?; + cull(s, p, |s| for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))) } - let v = for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))?; - advance_if(s, p); - Ok(v) - }), - cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| { - change_rating(s, p, -1, "Decrease Rating") - }), - cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| { - change_rating(s, p, 1, "Increase Rating") - }), - cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| { - let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; - let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?; - advance_if(s, p); - Ok(v) - }), + ), + cmd!( + "photo.decreaseRating", + "Decrease Rating", + [], + None, + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_rating(s, p, -1, "Decrease Rating")) } + ), + cmd!( + "photo.increaseRating", + "Increase Rating", + [], + None, + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_rating(s, p, 1, "Increase Rating")) } + ), + cmd!( + "photo.flag", + "Set Flag", + ["Photo", "Set Flag"], + None, + "{flag: pick|reject|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; + cull(s, p, |s| for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))) + } + ), // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) cmd!( "photo.cycleFlag", "Cycle Flag", ["Photo", "Set Flag"], Some("`"), - "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged", + "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged; without `ids`, the selection moves on when this takes the active photo out of the view", has_selection, |s, p| { - change_flag(s, p, "Cycle Flag", |f| match f { - Flag::None => Flag::Pick, - Flag::Pick => Flag::Reject, - Flag::Reject => Flag::None, + cull(s, p, |s| { + change_flag(s, p, "Cycle Flag", |f| match f { + Flag::None => Flag::Pick, + Flag::Pick => Flag::Reject, + Flag::Reject => Flag::None, + }) }) } ), @@ -806,34 +863,52 @@ pub fn specs() -> Vec<CommandSpec> { "Increase Flag Status", ["Photo", "Set Flag"], Some("Cmd+Up"), - "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)", + "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick); without `ids`, the selection moves on when this takes the active photo out of the view", has_selection, - |s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) } + |s, p| { cull(s, p, |s| change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick })) } ), cmd!( "photo.decreaseFlag", "Decrease Flag Status", ["Photo", "Set Flag"], Some("Cmd+Down"), - "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)", + "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject); without `ids`, the selection moves on when this takes the active photo out of the view", has_selection, - |s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) } + |s, p| { cull(s, p, |s| change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject })) } + ), + cmd!( + "photo.pick", + "Flag as Pick", + ["Photo", "Set Flag"], + Some("P"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))) } + ), + cmd!( + "photo.reject", + "Flag as Reject", + ["Photo", "Set Flag"], + Some("X"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))) } + ), + cmd!( + "photo.unflag", + "Unflag", + ["Photo", "Set Flag"], + Some("U"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))) } ), - cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| { - for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick })) - }), - cmd!("photo.reject", "Flag as Reject", ["Photo", "Set Flag"], Some("X"), "{ids?}", has_selection, |s, p| { - for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject })) - }), - cmd!("photo.unflag", "Unflag", ["Photo", "Set Flag"], Some("U"), "{ids?}", has_selection, |s, p| { - for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None })) - }), cmd!( "photo.label", "Set Color Label", ["Photo", "Set Color Label"], None, - "{label: red|yellow|green|blue|purple|none, ids?}", + "{label: red|yellow|green|blue|purple|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", has_selection, |s, p| { // `none` (or null) clears; a missing label or one that isn't text is an error, not a clear @@ -844,7 +919,7 @@ pub fn specs() -> Vec<CommandSpec> { None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")), Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")), }; - for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l })) + cull(s, p, |s| for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))) } ), // ---- orientation @@ -915,6 +990,8 @@ pub fn specs() -> Vec<CommandSpec> { cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { let before = s.visible_cloned(); let targets = s.targets(p)?; + // an open drag ends first, as its own undo step (the selection moves on) + s.end_interaction()?; let n = mark_deleted(s, &targets, "Delete")?; select_after_deletion(s, &before); Ok(json!({"changed": n})) @@ -944,6 +1021,8 @@ pub fn specs() -> Vec<CommandSpec> { cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let before = s.visible_cloned(); let t = s.targets(p)?; + // before the inverse ops are taken, so undo brings the photo back with its edit + s.end_interaction()?; let op = s.catalog.delete_photos_permanently_ops(&t); s.commit("Delete Permanently", op)?; select_after_deletion(s, &before); @@ -1635,7 +1714,7 @@ pub fn specs() -> Vec<CommandSpec> { s.compact_library()?; ok() }), - cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| { + cmd!("library.clearPreviews", "Discard Preview Cache", ["File"], None, "{}", always, |s, _| { s.media.rendered.clear(); s.media.clear_sources(); ok() @@ -1804,6 +1883,14 @@ mod relative_rating_tests; #[path = "library/tests_flag_cycle.rs"] mod flag_cycle_tests; +#[cfg(test)] +#[path = "library/tests_rated_out.rs"] +mod rated_out_tests; + #[cfg(test)] #[path = "library/tests_enable_filters.rs"] mod enable_filters_tests; + +#[cfg(test)] +#[path = "library/tests_source_album_filter.rs"] +mod source_album_filter_tests; diff --git a/crates/engine/src/cmd/library/tests_rated_out.rs b/crates/engine/src/cmd/library/tests_rated_out.rs new file mode 100644 index 000000000..49c36596b --- /dev/null +++ b/crates/engine/src/cmd/library/tests_rated_out.rs @@ -0,0 +1,139 @@ +//! A rating, flag or label that takes the active photo out of the view (the filter bar, Picks) goes on from its place: +//! the photo that took it, not the view's first photo (#2312). +use lightcraft_catalog::{ColorLabel, Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Result, Selection, Session}; + +/// 100 photos with one star each, in file-name order (photo `n` is the `n`th), the filter showing one-star photos. +fn one_star() -> Result<Session> { + let mut s = Session::new(); + for id in 1..=100 { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-rated-out/{id:03}.jpg") }, + &format!("{id:03}.jpg"), + "JPEG", + 40, + 30, + "2026-10-19", + ); + photo.rating = 1; + photo.flag = Flag::Pick; + photo.label = Some(ColorLabel::Red); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?; + s.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"}))?; + Ok(s) +} + +#[test] +fn a_star_taken_off_goes_on_to_the_next_photo() { + // Given photo 82 of 100 one-star photos, the filter showing one-star photos + let mut s = one_star().unwrap(); + s.execute("library.select", &json!({"ids": [82]})).unwrap(); + // When its star is taken off, Then it leaves the view and the photo after it is the one selected + s.execute("photo.rate", &json!({"rating": 0})).unwrap(); + assert!(!s.visible_cloned().contains(&PhotoId(82))); + assert_eq!(s.selection, Selection::single(PhotoId(83))); + // and the next photo is the one after that, not the first one of the view + s.execute("library.next", &json!({})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(84))); + // With `advance`, the photo that took its place is the next one: no photo is skipped + s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(85))); + s.execute("photo.decreaseRating", &json!({"advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(86))); + // The last photo of the view hands on to the one before it + s.execute("library.select", &json!({"ids": [100]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(99))); + // A rating that keeps the photo in the view advances as before + s.execute("photo.rate", &json!({"rating": 1, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(99)), "the last photo of the view stays"); + s.execute("library.select", &json!({"ids": [10]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 1, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(11))); +} + +#[test] +fn flags_and_labels_that_leave_the_view_go_on_to_the_next_photo_too() { + for (filter, command, params) in [ + (json!({"flag": "pick"}), "photo.reject", json!({})), + (json!({"flag": "pick"}), "photo.flag", json!({"flag": "none"})), + (json!({"flag": "pick"}), "photo.cycleFlag", json!({})), + (json!({"flag": "pick"}), "photo.decreaseFlag", json!({})), + (json!({"label": "red"}), "photo.label", json!({"label": "none"})), + (json!({}), "photo.increaseRating", json!({})), + ] { + let mut s = one_star().unwrap(); + s.execute("library.filter", &filter).unwrap(); + s.execute("library.select", &json!({"ids": [40]})).unwrap(); + s.execute(command, &params).unwrap(); + assert!(!s.visible_cloned().contains(&PhotoId(40)), "{command} {params}"); + assert_eq!(s.selection, Selection::single(PhotoId(41)), "{command} {params}"); + } +} + +#[test] +fn several_photos_rated_out_go_on_after_the_active_one() { + let mut s = one_star().unwrap(); + // three selected, the middle one active: all leave, the photo after the active one is selected + s.execute("library.select", &json!({"ids": [20, 21, 22], "active": 21})).unwrap(); + s.execute("photo.rate", &json!({"rating": 0})).unwrap(); + assert_eq!(s.selection, Selection::single(PhotoId(23))); + // the active one leaves and another selected photo stays: that one is active + s.execute("photo.rate", &json!({"ids": [30], "rating": 3})).unwrap(); + s.execute("library.filter", &json!({"rating": 1, "ratingOp": "atLeast"})).unwrap(); + s.execute("library.select", &json!({"ids": [29, 30], "active": 29})).unwrap(); + s.execute("photo.decreaseRating", &json!({})).unwrap(); + assert_eq!(s.selection, Selection { ids: vec![PhotoId(30)], active: Some(PhotoId(30)) }); +} + +#[test] +fn named_photos_and_a_held_view_keep_the_selection() { + // Photos named by `ids` leave the selection to the caller (Compare and Survey keep the photo they show) + let mut s = one_star().unwrap(); + s.execute("library.select", &json!({"ids": [5, 6], "active": 6})).unwrap(); + s.execute("photo.rate", &json!({"ids": [6], "rating": 0})).unwrap(); + assert_eq!(s.selection, Selection { ids: vec![PhotoId(5), PhotoId(6)], active: Some(PhotoId(6)) }); + // A held view keeps the photo, so the selection stays with it + let mut s = one_star().unwrap(); + s.execute("library.holdView", &json!({"on": true})).unwrap(); + // (what the grid shows is what is held) + assert_eq!(s.visible_cloned().len(), 100); + s.execute("library.select", &json!({"ids": [7]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 0})).unwrap(); + assert!(s.visible_cloned().contains(&PhotoId(7))); + assert_eq!(s.selection, Selection::single(PhotoId(7))); + s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(8))); +} + +/// A slider drag still open when a rating takes the photo out of the view, or when the photo is +/// deleted, ends as its own undo step before that change, as moving to the next photo does: undo +/// brings the rating or the photo back first, then the edit. Before, the move left the drag open +/// with no undo step (CodeRabbit on #2689). +#[test] +fn an_open_edit_ends_before_the_selection_moves_on() { + let exposure = |s: &Session, id| s.develop_of(PhotoId(id)).map(|d| d.light.exposure); + for (command, params) in [("photo.rate", json!({"rating": 0})), ("photo.delete", json!({})), ("photo.deletePermanently", json!({}))] { + let mut s = one_star().unwrap(); + s.execute("library.select", &json!({"ids": [82]})).unwrap(); + s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap(); + s.execute(command, &params).unwrap(); + assert_eq!(s.active(), Some(PhotoId(83)), "{command}"); + assert!(s.interaction.is_none(), "{command}: the drag ended with the move"); + if command != "photo.deletePermanently" { + assert_eq!(exposure(&s, 82), Some(1.5), "{command}"); + } + // undo: the rating or the photo comes back first, with the edit; then the edit goes + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.visible_cloned().contains(&PhotoId(82)), "{command}: back in the view"); + assert_eq!(exposure(&s, 82), Some(1.5), "{command}: with its edit"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(exposure(&s, 82), Some(0.0), "{command}"); + } +} diff --git a/crates/engine/src/cmd/library/tests_source_album_filter.rs b/crates/engine/src/cmd/library/tests_source_album_filter.rs new file mode 100644 index 000000000..599ffdeb3 --- /dev/null +++ b/crates/engine/src/cmd/library/tests_source_album_filter.rs @@ -0,0 +1,61 @@ +//! `library.source` with an album while the filter holds an album: the album shown wins. +use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Result, Selection, Session}; + +/// Photos 1 to 3 (rated 5, 5 and 2). Album X holds photo 1 and album A holds photo 3. +fn session_with_two_albums() -> Result<(Session, u64, u64)> { + let mut s = Session::new(); + for (id, rating) in [(1, 5), (2, 5), (3, 2)] { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-source-album/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-23", + ); + photo.rating = rating; + photo.flag = Flag::None; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + let mut make = |name: &str, id: u64| { + s.selection = Selection { ids: vec![PhotoId(id)], active: None }; + s.execute("album.create", &json!({"name": name, "addSelected": true})).unwrap()["id"].as_u64().unwrap() + }; + let (x, a) = (make("X", 1), make("A", 3)); + Ok((s, x, a)) +} + +#[test] +fn showing_an_album_drops_the_album_the_filter_held() { + let (mut s, x, a) = session_with_two_albums().unwrap(); + s.execute("library.filter", &json!({"album": x, "rating": 4})).unwrap(); + assert_eq!(s.visible().len(), 1, "the album narrows the grid, or the hidden album would not matter"); + s.execute("library.source", &json!({"kind": "album", "id": a})).unwrap(); + assert_eq!(s.filter.album, None, "one album at a time: the one shown wins, as for a library folder"); + assert_eq!(s.filter.rating, 4, "the rest of the filter stays"); + // the old album must not narrow the next source, with no chip to show it + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert_eq!(s.visible().len(), 2, "photos 1 and 2 have four stars or more"); +} + +#[test] +fn the_album_filter_stays_while_the_source_is_not_an_album() { + let (mut s, x, _) = session_with_two_albums().unwrap(); + s.execute("library.filter", &json!({"album": x})).unwrap(); + s.execute("library.source", &json!({"kind": "picks"})).unwrap(); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert_eq!(s.filter.album.map(|a| a.0), Some(x)); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn a_refused_album_source_leaves_the_filter_alone() { + let (mut s, x, _) = session_with_two_albums().unwrap(); + s.execute("library.filter", &json!({"album": x})).unwrap(); + assert!(s.execute("library.source", &json!({"kind": "album", "id": 9999})).is_err()); + assert_eq!(s.filter.album.map(|a| a.0), Some(x)); +} diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 339b20b90..9b1f7e9f7 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> { "Edit Capture Time", [], None, - "{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..]}", + "{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..], unreadable: photos skipped because their capture time can't be read}", has_selection, |s, p| { use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso}; @@ -213,12 +213,25 @@ pub fn specs() -> Vec<CommandSpec> { let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or("")) .and_then(|_| iso_seconds(&anchor_date)) .ok_or_else(|| bad(c, "the photo's date doesn't parse"))?; - delta += iso_seconds(&t).unwrap_or(from) - from; + // the others move by the anchor's change, so it must read in full, not just + // its leading digits (a Lightroom catalog's capture time is copied verbatim); + // alone, it simply gets the time, which repairs it + if shift_iso(&anchor_date, 0).is_some() { + delta += iso_seconds(&t).unwrap_or(from) - from; + } else if targets.len() == 1 { + each = Some(t); + } else { + return Err(bad( + c, + "the active photo's capture time can't be read, so the other photos can't move by the same amount; set its time with it selected alone", + )); + } } } let mut ops = Vec::new(); let mut out = Vec::new(); let mut out_of_range = 0usize; + let mut unreadable = 0usize; for id in &targets { let from = match &each { Some(t) => t.clone(), @@ -226,9 +239,11 @@ pub fn specs() -> Vec<CommandSpec> { }; let Some(new) = shift_iso(&from, delta) else { // a date that reads but would leave years 0000–9999 is refused below; one - // that doesn't read is skipped, as before - if iso_seconds(&from).is_some() { + // that doesn't read is skipped and counted + if shift_iso(&from, 0).is_some() { out_of_range += 1; + } else { + unreadable += 1; } continue; }; @@ -244,7 +259,7 @@ pub fn specs() -> Vec<CommandSpec> { if n > 0 { s.commit("Edit Capture Time", Op::Batch { ops })?; } - Ok(json!({"changed": n, "captured": out})) + Ok(json!({"changed": n, "captured": out, "unreadable": unreadable})) } ), cmd!( diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index c6c692c35..c06997eef 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -90,9 +90,77 @@ fn stored_hash(p: &Photo) -> Option<&str> { p.content_hash.as_deref().map(|h| h.split(':').next().unwrap_or(h)).filter(|h| !h.is_empty()) } -/// The content hash of the file at `path` (as import computes it), if it can be read. +/// The content hash of the file at `path` (as import computes it), if it can be read. Read in +/// pieces: the file is whatever is on the disk, of any size. fn file_hash(path: &str) -> Option<String> { - std::fs::read(path).ok().map(|b| lightcraft_preview::hash_bytes(&b).to_string()) + hash_reader(std::fs::File::open(path).ok()?) +} + +/// The content hash of what `r` gives, read in pieces (the hash import computes of the whole). +fn hash_reader(mut r: impl std::io::Read) -> Option<String> { + let mut hasher = lightcraft_preview::Hasher128::new(); + let mut piece = vec![0u8; 1 << 16]; + loop { + match r.read(&mut piece) { + Ok(0) => return Some(hasher.finish().to_string()), + Ok(n) => { + hasher.update(piece.get(..n)?); + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reader that gives its bytes a few at a time, and an interruption in between. + struct Trickle<'a>(&'a [u8], bool); + + impl std::io::Read for Trickle<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + self.1 = !self.1; + if self.1 { + return Err(std::io::ErrorKind::Interrupted.into()); + } + let n = self.0.len().min(buf.len()).min(7); + buf[..n].copy_from_slice(&self.0[..n]); + self.0 = &self.0[n..]; + Ok(n) + } + } + + #[test] + fn a_hash_read_in_pieces_is_the_hash_of_the_whole() { + // longer than one piece, so the read loop runs more than once + let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect(); + let whole = lightcraft_preview::hash_bytes(&bytes).to_string(); + assert_eq!(hash_reader(bytes.as_slice()).as_deref(), Some(whole.as_str())); + assert_eq!(hash_reader(Trickle(&bytes[..1000], false)).as_deref(), Some(lightcraft_preview::hash_bytes(&bytes[..1000]).to_string().as_str())); + assert_eq!(hash_reader(std::io::empty()).as_deref(), Some(lightcraft_preview::hash_bytes(&[]).to_string().as_str())); + } + + /// A scratch folder that goes away with the test, however it ends. + struct Scratch(std::path::PathBuf); + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn a_file_hash_is_the_import_hash_and_a_missing_file_has_none() { + let dir = Scratch(std::env::temp_dir().join(format!("lc-file-hash-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&dir.0); + std::fs::create_dir_all(&dir.0).unwrap(); + let f = dir.0.join("a.bin"); + std::fs::write(&f, b"pixels").unwrap(); + assert_eq!(file_hash(f.to_str().unwrap()), Some(lightcraft_preview::hash_bytes(b"pixels").to_string())); + assert_eq!(file_hash(dir.0.join("nope").to_str().unwrap()), None); + } } /// An in-scope photo ([`checked_path`]) as Find Missing Photos sees it: its file, the size and diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index dfbf4bfc5..a3926bc51 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -1,6 +1,8 @@ //! Read-only queries (not journaled): catalog, photos, develop state, controls, presets, albums. -use lightcraft_catalog::{Album, Photo, PhotoId}; +use std::collections::HashMap; + +use lightcraft_catalog::{Album, AlbumId, Photo, PhotoId}; use lightcraft_develop::{CONTROLS, controls}; use serde_json::{Value, json}; @@ -36,9 +38,9 @@ pub fn photo_summary(p: &Photo) -> Value { /// instead of overflowing the stack. const MAX_ALBUM_DEPTH: usize = 64; -fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth: usize) -> Value { +fn album_json(a: &Album, all: &[Album], counts: &HashMap<AlbumId, usize>, cat: &lightcraft_catalog::Catalog, depth: usize) -> Value { let children: Vec<Value> = if depth < MAX_ALBUM_DEPTH { - all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat, depth + 1)).collect() + all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, counts, cat, depth + 1)).collect() } else { Vec::new() }; @@ -46,7 +48,7 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth "id": a.id.0, "name": a.name, "folder": a.folder, - "count": cat.album_count(a.id), + "count": counts.get(&a.id).copied().unwrap_or(0), "cover": a.cover.map(|c| c.0), "children": children, }); @@ -152,7 +154,9 @@ pub fn specs() -> Vec<CommandSpec> { }), cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| { let all: Vec<Album> = s.catalog.albums().cloned().collect(); - Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog, 0)).collect())) + // every album counted in one pass, so a smart album tested by others is worked out once + let counts = lightcraft_catalog::tally::AlbumTally::of(&s.catalog); + Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, counts.counts(), &s.catalog, 0)).collect())) }), cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| { let id = photo_arg(s, p, "photo.inspect")?; diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 431eec763..38a42fe95 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -31,7 +31,7 @@ pub fn load(s: &mut Session) { ..Default::default() }; // one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so - // View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file + // View ▸ Focus Point and Zoom to Focus Point can be tried without a camera file if sc.name == "Cosmos bloom" { p.meta.focus = Some(lightcraft_meta::FocusPoint { rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 }, diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 2580aefd8..236387b4d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -501,11 +501,12 @@ pub(crate) fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> { } /// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the -/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded. +/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it. +/// The error says why it couldn't be read or decoded. pub fn read_graphic(path: &str) -> Result<Rgba8, String> { let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; - Ok(decoded.to_srgb8()) + Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation))) } /// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to @@ -518,20 +519,27 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 { return; } - let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width); - let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height); + let turns = wm.quarter_turns(); + // Reserve the inset on anchored axes before fitting the turned box. Centered axes + // ignore insets, just as placement below does. One scale preserves the graphic's shape. + let short = width.min(height) as f32; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); + use Anchor::*; + let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 }; + let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 }; + let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) }; + let scale = + (wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64); + let lw = (logo.width as f64 * scale).round().max(1.0) as usize; + let lh = (logo.height as f64 * scale).round().max(1.0) as usize; let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| { let p = logo.get(x, y); let a = p[3] as f32 / 255.0; [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] }); let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); - let short = width.min(height) as f32; - let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); - let turns = wm.quarter_turns(); let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) }; let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32); - use Anchor::*; let ox = match wm.anchor { TopLeft | Left | BottomLeft => ix, Top | Center | Bottom => (w - lwf) / 2.0, @@ -658,8 +666,8 @@ pub struct ExportOptions { /// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`]. pub bit_depth: Option<u8>, /// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR - /// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb` - /// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers + /// rendition plus a gain map, so the file looks right everywhere; `limit_kb` includes both + /// images and their metadata), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers /// without cICP support show it as sRGB, washed out), JPEG XL as 16-bit Rec. 2020 PQ, 32-bit float /// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR /// edit, are SDR. @@ -1392,29 +1400,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada ExportFormat::Jpeg => { // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta); - match o.limit_kb { - Some(kb) => { - let limit = kb as usize * 1024; - // Binary search for the highest quality that fits. - let (mut lo, mut hi) = (1u8, o.quality); - let mut best = None; - while lo <= hi { - let mid = lo + (hi - lo) / 2; - let b = jpeg(mid).map_err(|e| e.to_string())?; - if b.len() <= limit { - best = Some(b); - lo = mid + 1; - } else if mid == 1 { - break; - } else { - hi = mid - 1; - } - } - return best.ok_or_else(|| format!("cannot fit within {kb} KB")); - } - None => jpeg(o.quality), - } + return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta)); } ExportFormat::Png => encode::encode_png(&e, &meta), ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), @@ -1428,6 +1414,27 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada r.map_err(|e| e.to_string()) } +/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs. +fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result<Vec<u8>>) -> Result<Vec<u8>, String> { + let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) }; + let limit = u64::from(kb) * 1024; + let (mut lo, mut hi) = (1u8, o.quality.min(100)); + let mut best = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let b = jpeg(mid).map_err(|e| e.to_string())?; + if b.len() as u64 <= limit { + best = Some(b); + lo = mid + 1; + } else if mid == 1 { + break; + } else { + hi = mid - 1; + } + } + best.ok_or_else(|| format!("cannot fit within {kb} KB")) +} + /// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { let mut img = img.clone(); @@ -1475,7 +1482,7 @@ pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, met let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string()) + encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em)) } /// The codecs' name of an output space (for its ICC profile). @@ -2441,6 +2448,139 @@ mod tests { assert!(b.len() <= kb as usize * 1024); } + #[test] + fn hdr_jpeg_size_limit_respected() { + let img = Rgba8::from_fn(256, 192, |x, y| { + let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663); + [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255] + }); + let hdr = DeepImage { + width: img.width, + height: img.height, + space: OutputSpace::Srgb, + samples: DeepSamples::F32( + img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(), + ), + }; + let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() }; + let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap(); + let kb = (full.len() / 2048) as u32; + let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap(); + assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len()); + let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap(); + assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting"); + let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None); + assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB"))); + } + + #[test] + fn graphic_watermark_honours_exif_orientation() { + let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }); + for orientation in [2, 6] { + let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() }; + let exif = lightcraft_meta::write_exif(&meta); + let bytes = lightcraft_codecs::encode_jpeg( + &EncodeImage::rgba8(&logo), + 100, + ChromaSubsampling::S444, + &EncodeMeta { exif: Some(&exif), ..Default::default() }, + ) + .unwrap(); + let path = dir.join(format!("oriented-{orientation}.jpg")); + std::fs::write(&path, bytes).unwrap(); + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: if orientation == 6 { 0.25 } else { 0.5 }, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + ..Default::default() + }; + let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + if orientation == 6 { + assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue"); + } else { + assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red"); + } + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() { + let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + rotation, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1, + lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1, + ); + assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn graphic_watermark_fit_preserves_anchor_insets() { + let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + use Anchor::*; + for (anchor, rotation, expected) in [ + (BottomRight, 90, (0, 44, 111, 71)), + (TopLeft, 270, (8, 8, 119, 35)), + (Right, 90, (0, 26, 111, 53)), + (BottomRight, 0, (94, 0, 111, 71)), + (TopLeft, 180, (8, 8, 25, 79)), + (Bottom, 0, (51, 0, 68, 71)), + // Centered axes ignore their inset rather than shrinking the image unnecessarily. + (Center, 90, (0, 25, 119, 54)), + (Top, 90, (0, 8, 119, 37)), + ] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.1, + inset_y: 0.1, + rotation, + anchor, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).min().unwrap(), + lit.iter().map(|p| p.1).min().unwrap(), + lit.iter().map(|p| p.0).max().unwrap(), + lit.iter().map(|p| p.1).max().unwrap(), + ); + assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn sharpen_increases_edge_contrast() { let mut img = Rgba8::new(8, 8); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 62bd33820..bb81bd240 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -374,6 +374,42 @@ pub fn is_supported(path: &Path) -> bool { path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) } +/// The web file picker's accept list, derived from [`EXTENSIONS`]. Each extension +/// is listed in both cases: browsers' `accept` matching is case-insensitive per +/// spec, but some implementations (e.g. Windows/Edge) match case-sensitively and +/// rejected uppercase `.NEF` files, so a lowercase-only list silently hid raws +/// such as CR3, NRW, RAF, ORF, RW2, RWL, RAW, PEF and SRW from the picker. +/// +/// The picker only offers files this build can actually import: `.avif` has no +/// AV1 decoder in any build, and `.heic`/`.heif` decode only when the codecs' +/// `heif` feature is enabled ([`lightcraft_codecs::Format::Heif::can_decode`]). +/// [`EXTENSIONS`] itself stays complete — files on disk are still recognised, +/// and import reports them as failed, saying why. +pub fn accept_list() -> String { + let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); + let mut out = String::new(); + for &ext in EXTENSIONS { + // Skip extensions a web user could pick but this build could not import. + let pickable = match ext { + "avif" => false, + "heic" | "heif" => heif_decodable, + _ => true, + }; + if !pickable { + continue; + } + if !out.is_empty() { + out.push(','); + } + out.push('.'); + out.push_str(ext); + out.push(','); + out.push('.'); + out.push_str(&ext.to_uppercase()); + } + out +} + /// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder) /// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a /// walk that stops at a bound is logged. @@ -1607,6 +1643,52 @@ mod tests_known; mod prepared_tests { use super::*; + /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the + /// files this build cannot import (see [`accept_list`]). + fn pickable_extensions() -> Vec<&'static str> { + let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); + EXTENSIONS + .iter() + .copied() + .filter(|ext| match *ext { + "avif" => false, + "heic" | "heif" => heif_decodable, + _ => true, + }) + .collect() + } + + #[test] + fn accept_list_covers_every_pickable_extension_in_both_cases() { + let list = accept_list(); + for ext in pickable_extensions() { + assert!(list.contains(&format!(".{ext}")), "lowercase .{ext} missing from accept list"); + assert!(list.contains(&format!(".{}", ext.to_uppercase())), "uppercase .{} missing from accept list", ext.to_uppercase()); + } + } + + /// The web picker must not offer files this build cannot import: `.avif` has + /// no AV1 decoder in any build, so it is never listed, in either case. + #[test] + fn accept_list_never_offers_avif() { + let list = accept_list(); + assert!(!list.contains(".avif"), "{list}"); + assert!(!list.contains(".AVIF"), "{list}"); + } + + /// `.heic`/`.heif` follow the codecs' `heif` feature: listed (in both cases) + /// only when this build can decode them; otherwise hidden, so a web user + /// cannot pick a file that would fail to import. + #[test] + fn accept_list_offers_heic_heif_only_when_decodable() { + let list = accept_list(); + let listed = lightcraft_codecs::Format::Heif.can_decode(); + for ext in ["heic", "heif"] { + assert_eq!(list.contains(&format!(".{ext}")), listed, "lowercase .{ext}"); + assert_eq!(list.contains(&format!(".{}", ext.to_uppercase())), listed, "uppercase .{ext}"); + } + } + #[test] fn revalidate_add_only_converts_matching_ready_item() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4ad5695dc..ff2b972c0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -195,6 +195,31 @@ pub struct Interaction { /// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. const MAX_FOLLOWED: usize = 256; +/// The established clock precision for relative-date views and sidebar counts. +fn clock_minute(now: &str) -> &str { + now.get(..16).unwrap_or(now) +} + +/// Whether a query can change membership without a catalog edit, including the smart albums it tests. +fn filter_depends_on_clock(filter: &Filter, catalog: &Catalog) -> bool { + if filter.depends_on_now() { + return true; + } + let mut pending = filter.albums_tested(); + let mut seen = std::collections::HashSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + let Some(smart) = catalog.album(id).and_then(|album| album.smart.as_deref()) else { continue }; + if smart.depends_on_now() { + return true; + } + pending.extend(smart.albums_tested()); + } + false +} + /// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one). static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -215,6 +240,8 @@ pub struct Session { /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). visible: Arc<[PhotoId]>, visible_key: Option<(u64, String)>, + /// The minute used for a cached view whose rules depend on the session clock. + visible_clock_minute: Option<String>, /// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop /// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once. pub hold_view: bool, @@ -234,7 +261,7 @@ pub struct Session { /// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids. visible_gen: u64, /// Photos in the current source with no filter on (cached like `visible`). - total: Option<((u64, String), usize)>, + total: Option<((u64, String), usize, Option<String>)>, pub undo: Vec<UndoEntry>, /// Undo steps committed in this session, ever (the history drops its oldest past 1000, so /// its length can't tell how many steps a command just made; see [`Session::commits`]). @@ -429,6 +456,7 @@ impl Session { selection: Selection::default(), visible: Vec::new().into(), visible_key: None, + visible_clock_minute: None, hold_view: false, base: None, view_followed: 0, @@ -1050,12 +1078,20 @@ impl Session { }; let view = format!("{}|{rules}", key.1); 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())); + // the view fills in as the background checks find files gone (a file found there at + // its first check changes nothing it shows) + key.1.push_str(&format!("|{}", self.media.availability.gone_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)())); + let key_changed = self.visible_key.as_ref() != Some(&key); + // A time-dependent cache needs one clock read to test its minute stamp. A new view also + // needs one value for the query, even when its previous cached view did not use the clock. + let current_now = (key_changed || self.visible_clock_minute.is_some()).then(|| (self.clock)()); + let current_minute = current_now.as_deref().map(clock_minute); + let clock_changed = self.visible_clock_minute.as_deref().is_some_and(|cached| current_minute != Some(cached)); + if key_changed || clock_changed { + // Use this same value to evaluate relative rules and stamp the result. + let now = current_now.unwrap_or_else(|| (self.clock)()); + lightcraft_catalog::rules::set_now(Some(now.clone())); let f = self.view_filter(self.source, &self.filter); // A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's // rules, a flag, the filter bar), as long as they are still where the view looks: in the library for @@ -1082,8 +1118,7 @@ impl Session { let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing); // A view that asks about "the last N days" (itself, or through a smart album it looks into) changes // with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole. - let clocked = f.depends_on_now() - || (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now))); + let clocked = filter_depends_on_clock(&f, &self.catalog); let base = self.base.take().filter(|(of, _, _)| *of == view); // A small edit to the same view: the photos it touched are taken out, asked about again, and put back // at their place in the order. The others neither stopped matching nor moved. @@ -1157,6 +1192,7 @@ impl Session { self.visible = visible.into(); self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.visible_key = Some(key); + self.visible_clock_minute = clocked.then(|| clock_minute(&now).to_owned()); } &self.visible } @@ -1189,12 +1225,20 @@ impl Session { /// view, so a list followed many times over is compared, not only one followed once. #[cfg(test)] pub(crate) fn visible_in_full(&mut self) -> Vec<PhotoId> { - let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out); + let kept = ( + self.base.clone(), + self.visible_key.clone(), + self.visible_clock_minute.clone(), + self.visible.clone(), + self.visible_gen, + self.view_followed, + self.view_worked_out, + ); self.never_follow = true; self.visible_key = None; let whole = self.visible().to_vec(); self.never_follow = false; - (self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept; + (self.base, self.visible_key, self.visible_clock_minute, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept; whole } @@ -1222,7 +1266,15 @@ impl Session { } } let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); - if self.total.as_ref().map(|t| &t.0) != Some(&key) { + let key_changed = self.total.as_ref().map(|total| &total.0) != Some(&key); + let cached_minute = self.total.as_ref().and_then(|total| total.2.as_deref()); + let current_now = cached_minute.map(|_| (self.clock)()); + let current_minute = current_now.as_deref().map(clock_minute); + let clock_changed = cached_minute.is_some_and(|cached| current_minute != Some(cached)); + if key_changed || clock_changed { + // The source count can be requested before `visible`; bind it to the session's clock itself. + let now = current_now.unwrap_or_else(|| (self.clock)()); + lightcraft_catalog::rules::set_now(Some(now.clone())); let mut f = self.source.to_filter(&Filter::default(), &self.catalog); if self.source == LibrarySource::Folder { let b = self.browse.clone().unwrap_or_default(); @@ -1234,7 +1286,8 @@ impl Session { } // (how many, not which: nothing to put in order) let n = self.catalog.count(&f); - self.total = Some((key, n)); + let clocked = filter_depends_on_clock(&f, &self.catalog); + self.total = Some((key, n, clocked.then(|| clock_minute(&now).to_owned()))); } self.total.as_ref().map(|t| t.1) } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 292a9fb1e..e76ef8169 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 69; +pub const RENDER_CACHE_VERSION: u64 = 70; /// 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]; diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs index 26d1818e5..be4438424 100644 --- a/crates/engine/src/print/layout.rs +++ b/crates/engine/src/print/layout.rs @@ -46,6 +46,41 @@ const PAPER: &[(&str, f64, f64)] = &[ ("5x7", 5.0, 7.0), ("8x10", 8.0, 10.0), ("13x19", 13.0, 19.0), + // Japanese photo printing and postcard sizes, at their exact metric dimensions (the US photo + // sizes above are close but not identical: L is 89 x 127 mm, 5x7 in is 127 x 177.8 mm). + ("l", 89.0 / 25.4, 127.0 / 25.4), + ("2l", 127.0 / 25.4, 178.0 / 25.4), + ("kg", 102.0 / 25.4, 152.0 / 25.4), + ("postcard", 100.0 / 25.4, 148.0 / 25.4), + ("double-postcard", 148.0 / 25.4, 200.0 / 25.4), + ("6cut", 203.0 / 25.4, 254.0 / 25.4), + ("4cut", 254.0 / 25.4, 305.0 / 25.4), + ("business-card", 55.0 / 25.4, 91.0 / 25.4), +]; + +/// Japanese customary names (and a few English spellings) for the papers in [`PAPER`], matched +/// after [`normalize_paper`]: `L判` is `l`, `はがき` is `postcard`, `六切` is `6cut`, and so on. +const PAPER_ALIASES: &[(&str, &str)] = &[ + ("L判", "l"), + ("Lサイズ", "l"), + ("L-size", "l"), + ("2L判", "2l"), + ("2Lサイズ", "2l"), + ("2L-size", "2l"), + ("KG判", "kg"), + ("KGサイズ", "kg"), + ("KG-size", "kg"), + ("はがき", "postcard"), + ("ハガキ", "postcard"), + ("葉書", "postcard"), + ("ポストカード", "postcard"), + ("往復はがき", "double-postcard"), + ("往復ハガキ", "double-postcard"), + ("六切", "6cut"), + ("6切", "6cut"), + ("四切", "4cut"), + ("4切", "4cut"), + ("名刺", "business-card"), ]; /// Names accepted by [`PageSetup::paper`] (besides `custom`). @@ -53,10 +88,39 @@ pub fn paper_names() -> impl Iterator<Item = &'static str> { PAPER.iter().map(|p| p.0) } -/// Portrait size in inches of a named paper (case-insensitive), `None` for unknown names and `custom`. +/// The form [`paper_name`] matches names in: lower case, letters and digits only, and `×`/`*` read +/// as `x`, so `L判`, `L判。`, `l-size` and `(l)` are one name and `4 × 6` is `4x6`. +fn normalize_paper(name: &str) -> String { + name.chars() + .filter_map(|c| match c { + '×' | '*' => Some('x'), + c if c.is_alphanumeric() => Some(c), + _ => None, + }) + .collect::<String>() + .to_lowercase() +} + +/// The canonical name of the paper `name` stands for, `None` for unknown names and `custom`. +/// `L判` is `l`, `はがき` is `postcard`, `六切` is `6cut`, and so on. +pub fn paper_name(name: &str) -> Option<&'static str> { + let n = normalize_paper(name); + if n.is_empty() { + return None; + } + PAPER + .iter() + .find(|p| normalize_paper(p.0) == n) + .map(|p| p.0) + .or_else(|| PAPER_ALIASES.iter().find(|(alias, _)| normalize_paper(alias) == n).map(|(_, name)| *name)) +} + +/// Portrait size in inches of a named paper, `None` for unknown names and `custom`. Case, spaces +/// and punctuation don't matter, and the Japanese customary names (`L判`, `2L判`, `KG判`, `はがき`, +/// `往復はがき`, `六切`, `四切`, `名刺`) are aliases of the papers above. pub fn paper_size(name: &str) -> Option<(f64, f64)> { - let name = name.to_ascii_lowercase(); - PAPER.iter().find(|p| p.0 == name).map(|p| (p.1, p.2)) + let canonical = paper_name(name)?; + PAPER.iter().find(|p| p.0 == canonical).map(|p| (p.1, p.2)) } /// An axis-aligned rectangle in inches. @@ -935,6 +999,65 @@ mod tests { assert!(p.size().is_err()); } + /// Japanese photo and postcard papers: exact metric sizes to 0.1 pt, the customary names as + /// aliases, and the ISO/US papers around them unchanged. + #[test] + fn japanese_photo_and_postcard_papers() { + let pt = |name: &str| { + let p = PageSetup { paper: name.into(), ..Default::default() }; + p.size_points().unwrap_or_else(|e| panic!("{name}: {e}")) + }; + for (name, w_mm, h_mm) in [ + ("L判", 89.0, 127.0), + ("2L判", 127.0, 178.0), + ("KG判", 102.0, 152.0), + ("はがき", 100.0, 148.0), + ("往復はがき", 148.0, 200.0), + ("六切", 203.0, 254.0), + ("四切", 254.0, 305.0), + ("名刺", 55.0, 91.0), + ] { + let (w, h) = pt(name); + let want = (w_mm / 25.4 * 72.0, h_mm / 25.4 * 72.0); + assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h} pt, wanted {want:?}"); + assert!((w - h).abs() > 1e-6, "{name} is portrait"); + } + // The customary and English spellings resolve to the same paper, including spaces and case. + for (a, b) in [ + ("L判", "l"), + ("L判", " L "), + ("L判", "L-size"), + ("2L判", "2l"), + ("KG判", "kg"), + ("はがき", "postcard"), + ("はがき", "ハガキ"), + ("往復はがき", "double-postcard"), + ("六切", "6cut"), + ("六切", "6切"), + ("四切", "4cut"), + ("名刺", "business-card"), + ("A4", "a 4"), + ("4 × 6", "4x6"), + ] { + assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}"); + } + // Punctuation is ignored, including the periods and Japanese marks a pasted name may carry. + for (name, canonical) in [("L判。", "l"), ("L判、", "l"), ("(L判)", "l"), ("l-size.", "l"), ("はがき。", "postcard"), ("6切。", "6cut")] + { + assert_eq!(paper_name(name), Some(canonical), "{name}"); + } + assert_eq!(paper_name("custom"), None); + assert!(paper_size("poster").is_none()); + // The papers that were already there keep their sizes. + assert_eq!(pt("letter"), (612.0, 792.0)); + assert_eq!(pt("4x6"), (288.0, 432.0)); + assert_eq!(pt("5x7"), (360.0, 504.0)); + assert_eq!(pt("8x10"), (576.0, 720.0)); + assert_eq!(pt("13x19"), (936.0, 1368.0)); + let (w, h) = pt("A4"); + assert!((w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01, "A4 {w} x {h}"); + } + #[test] fn hostile_numbers_are_rejected() { for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index f0d725793..c569a2e1f 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -4,7 +4,7 @@ //! Template tokens (shared with import renaming and export file naming): `{name}` (current name //! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` / //! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date; -//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`, +//! `%Y %y %m %B %b %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`, //! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as //! typed. Characters that aren't allowed in file names become `-`. The original extension is always //! kept. @@ -25,6 +25,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use lightcraft_catalog::dates::MONTHS; use lightcraft_catalog::{Op, Photo, PhotoId, Source}; use serde::Serialize; @@ -57,9 +58,12 @@ fn sanitize(s: &str) -> String { t.trim().trim_matches('.').trim().to_string() } -/// `%Y%m%d`-style formatting of an ISO local time. +/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name +/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month +/// that doesn't parse gives nothing. fn format_date(iso: &str, fmt: &str) -> String { let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); + let month = iso.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or(""); let mut out = String::new(); let mut it = fmt.chars(); while let Some(c) = it.next() { @@ -71,6 +75,8 @@ fn format_date(iso: &str, fmt: &str) -> String { Some('Y') => out.push_str(&part(0, 4)), Some('y') => out.push_str(&part(2, 4)), Some('m') => out.push_str(&part(5, 7)), + Some('B') => out.push_str(month), + Some('b') => out.push_str(month.get(..3).unwrap_or(month)), Some('d') => out.push_str(&part(8, 10)), Some('H') => out.push_str(&part(11, 13)), Some('M') => out.push_str(&part(14, 16)), @@ -120,6 +126,8 @@ pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ ("%Y", "year, 4 digits"), ("%y", "year, 2 digits"), ("%m", "month 01–12"), + ("%B", "month name (January)"), + ("%b", "short month name (Jan)"), ("%d", "day 01–31"), ("%H", "hour 00–23"), ("%M", "minute"), @@ -1670,6 +1678,8 @@ pub(crate) mod tests { let mut p = sample_photo(); assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]); assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too"); + assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]); + assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]); // metadata can't add levels or climb out p.meta.camera = "../../etc/x".into(); p.meta.title = "..".into(); @@ -1711,6 +1721,12 @@ pub(crate) mod tests { let v = format_date("2026-01-14T05:58:48", d); assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive"); } + assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep"); + assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026"); + // a month that isn't 01–12 gives no name instead of panicking + for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] { + assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}"); + } assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]); assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty()); let json = token_help_json(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 218965812..93f4e6adc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -108,6 +108,20 @@ fn demo_library_loads() { assert!(albums.as_array().unwrap().iter().any(|a| a["folder"] == true)); } +#[test] +fn rating_rejects_fractional_values() { + let mut s = demo(); + let id = s.active().unwrap(); + for bad in [5.9, -0.9, 0.5] { + let err = s.execute("photo.rate", &json!({"rating": bad})).unwrap_err(); + assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}"); + assert_eq!(s.catalog.photo(id).unwrap().rating, 0, "{bad} changed the rating"); + } + s.execute("photo.rate", &json!({"rating": 5.0})).unwrap(); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().rating, 3); +} + #[test] fn rating_flag_undo_redo() { let mut s = demo(); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index a835ecba4..d0f174245 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -313,6 +313,30 @@ fn curve_preset_export_never_overwrites_originals_or_sidecars() { preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard"); } +/// #1944: Export Keywords went straight to `std::fs::write`, replacing a library original. +#[test] +fn keyword_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("keyword.export", json!({}), "keyword-export-guard"); +} + +#[test] +fn keyword_export_preserves_the_previous_file_on_write_failure() { + let dir = temp_dir("keyword-export-failure"); + let mut s = Session::with_demo(); + let path = dir.join("keywords.txt"); + let params = json!({"path": path.to_string_lossy()}); + s.execute("keyword.export", &params).unwrap(); + let previous = std::fs::read(&path).unwrap(); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(4); + assert!(s.execute("keyword.export", &params).is_err(), "keyword.export must report the failed write"); + } + assert_eq!(std::fs::read(&path).unwrap(), previous, "the prior export is kept"); + s.execute("keyword.export", &params).unwrap(); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no temporary files remain"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn preset_exports_preserve_previous_files_on_write_failure() { let dir = temp_dir("preset-export-failure"); diff --git a/crates/engine/src/tests_hold_view.rs b/crates/engine/src/tests_hold_view.rs index f7082498c..f30d08c4e 100644 --- a/crates/engine/src/tests_hold_view.rs +++ b/crates/engine/src/tests_hold_view.rs @@ -1,11 +1,28 @@ //! A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop //! matching a smart album's rules, Picks or the filter bar leaves it where it is, and the others where they are. -use lightcraft_catalog::PhotoId; +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; use serde_json::json; use crate::{LibrarySource, Session}; +fn relative_clock_session(captures: &[&str]) -> (Session, std::sync::Arc<std::sync::Mutex<String>>, Vec<PhotoId>) { + let mut s = Session::new(); + let now = std::sync::Arc::new(std::sync::Mutex::new("2026-10-24T12:00:00".to_string())); + let clock = now.clone(); + s.clock = Box::new(move || clock.lock().unwrap().clone()); + let mut ids = Vec::new(); + for (index, captured) in captures.iter().enumerate() { + let id = PhotoId(index as u64 + 1); + let name = format!("synthetic-{index}.jpg"); + let mut photo = Photo::new(id, Source::Demo { scene: 0 }, &name, "JPEG", 2, 2, "2026-10-01T00:00:00"); + photo.captured = Some((*captured).to_string()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + ids.push(id); + } + (s, now, ids) +} + /// The demo library with no photo keyworded, showing a smart album of the photos without keywords, at random. fn unkeyworded() -> Session { let mut s = Session::with_demo(); @@ -198,3 +215,29 @@ fn asking_for_the_state_it_has_changes_nothing_and_a_word_is_not_a_bool() { assert!(s.execute("library.holdView", &json!({"on": "false"})).is_err()); assert!(s.hold_view, "a refused call changes nothing"); } + +#[test] +fn relative_clock_held_view_keeps_expired_photos_admits_new_matches_and_refreshes() { + let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30", "2026-10-22T12:00:30"]); + let rules = json!({ + "ruleSet": { + "match": "any", + "rules": [ + {"field": "captureDate", "op": "inLast", "value": 1}, + {"field": "captureDate", "op": "notInLast", "value": 2} + ] + } + }); + s.execute("library.filter", &rules).unwrap(); + s.execute("library.holdView", &json!({"on": true})).unwrap(); + assert_eq!(s.visible_cloned(), vec![ids[0]], "only the soon-to-expire photo matches initially"); + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + let held = s.visible_cloned(); + assert_eq!(held.len(), 2, "the expired photo stays held while the newly matching photo enters: {held:?}"); + assert!(held.contains(&ids[0]), "the expired photo remains held"); + assert!(held.contains(&ids[1]), "the photo crossing the second relative boundary enters"); + + s.execute("library.refresh", &json!({})).unwrap(); + assert_eq!(s.visible_cloned(), vec![ids[1]], "explicit refresh releases the expired photo"); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index a688426ec..6e5585a82 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1068,6 +1068,54 @@ fn assisted_culling_groups_bursts_across_month_end() { let _ = std::fs::remove_dir_all(&dir); } +/// Assisted culling: `burstGap` and `similarity` move the burst limits (#2275), and values out of +/// range are refused before anything is measured or stored. +#[test] +fn assisted_culling_burst_limits_are_adjustable() { + let dir = temp_dir("cull-limits"); + for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] { + let img = lightcraft_scenes::demo_library()[0].render(w, h); + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }), + &Default::default(), + ) + .unwrap(); + std::fs::write(dir.join(name), png).unwrap(); + } + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap(); + let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png")); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()) }).unwrap(); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()) }).unwrap(); + // 15 s apart: past the default 10 s, inside a 20 s gap + assert_eq!(s.execute("photo.analyze", &json!({"ids": ids})).unwrap()["groups"], 0); + assert_eq!(s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20})).unwrap()["groups"], 1); + // the look-alike pair is a burst at the default similarity, not when it must be identical + let strict = s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20, "similarity": 1.0})).unwrap(); + assert_eq!(strict["groups"], 0, "{strict}"); + // bad values change nothing + let undo_before = s.commits(); + for bad in + [json!({"burstGap": -1}), json!({"burstGap": 86401}), json!({"burstGap": "10"}), json!({"similarity": 1.5}), json!({"similarity": -0.1})] + { + let mut p = bad.clone(); + p["ids"] = json!(ids); + assert!(s.execute("photo.analyze", &p).is_err(), "{bad} must be refused"); + } + assert_eq!(s.commits(), undo_before); + let _ = std::fs::remove_dir_all(&dir); +} + /// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step). #[test] fn dust_spots_are_found_and_healed() { diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 5460dc020..b2eb8cb94 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -314,6 +314,29 @@ fn smart_album_rule_sets() { assert!(a.to_string().contains("rating is ≥ 4"), "{a}"); } +/// `label` and `labels` set together (only through `library.filter`, or a filter saved with the +/// single label) AND, and the chip says what they let through, not their union. +#[test] +fn filter_label_and_labels_together_chip() { + use lightcraft_catalog::ColorLabel; + let mut s = crate::Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + for (id, l) in ids.iter().zip(["red", "yellow", "green"]) { + s.execute("photo.label", &json!({"ids": [id], "label": l})).unwrap(); + } + let reds = s.catalog.photos().filter(|p| p.in_library() && p.label == Some(ColorLabel::Red)).count(); + let chip = |s: &Session| crate::filter_chips(&s.filter, &s.catalog).into_iter().find(|c| c.label.starts_with("Label")).expect("a label chip"); + s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "red"]})).unwrap(); + assert_eq!(s.visible_cloned().len(), reds); + assert_eq!(chip(&s).label, "Label: Red"); + // nothing passes both: the chip names both constraints + s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "green"]})).unwrap(); + assert_eq!(s.visible_cloned().len(), 0); + assert_eq!(chip(&s).label, "Label: Red and (Yellow or Green)"); + s.execute("library.filter", &chip(&s).clear).unwrap(); + assert_eq!((s.filter.label, s.filter.labels.len()), (None, 0)); +} + /// The filter bar's label multi-select: any of the chosen labels. #[test] fn filter_any_of_several_labels() { @@ -514,6 +537,61 @@ fn capture_time_shift_is_bounded() { assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00"), "unchanged"); } +/// A capture time that doesn't fully read (a Lightroom catalog's `captureTime` is copied +/// verbatim) is left as it is and counted, not shifted into a clean-looking wrong time, and it +/// isn't mistaken for one that would leave years 0000–9999. +#[test] +fn capture_time_shift_counts_photos_whose_time_does_not_read() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (good, bad) = (ids[0], ids[1]); + for (id, t) in [(good, "2026-01-01T10:00:00"), (bad, "2026-01-01T10:00:00 and more")] { + s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap(); + } + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 1})).unwrap(); + assert_eq!(r["unreadable"], 1, "the unreadable photo is counted: {r}"); + assert_eq!(r["changed"], 1, "{r}"); + let captured = |s: &crate::Session, id| s.catalog.photo(id).unwrap().captured.clone(); + assert_eq!(captured(&s, good).as_deref(), Some("2026-01-01T11:00:00")); + assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T10:00:00 and more"), "left as it was"); + s.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert_eq!(captured(&s, good).as_deref(), Some("2026-01-01T10:00:00"), "one undo step"); + // a shift past year 9999 for the readable photo is still the out-of-range refusal, and the + // unreadable one alone isn't + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "hours": 24 * 365 * 9000})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(0), Some(1)), "{r}"); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 24 * 365 * 9000})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("1 photo(s) outside")), "{r:?}"); +} + +/// Setting the active photo's time moves the others by its change, so an active photo whose own +/// time doesn't read can't anchor it: refused, nothing changes. Alone, or setting every photo's +/// time, it gets the time, which repairs it. +#[test] +fn capture_time_set_refuses_an_active_photo_whose_time_does_not_read() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (bad, other) = (ids[0], ids[1]); + let broken = "2026-01-01T10:00:00 and more"; + for (id, t) in [(bad, broken), (other, "2026-01-01T12:00:00")] { + s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap(); + } + s.execute("library.select", &serde_json::json!({"ids": [bad.0, other.0], "active": bad.0})).unwrap(); + assert_eq!(s.active(), Some(bad)); + let captured = |s: &crate::Session, id| s.catalog.photo(id).unwrap().captured.clone(); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00"})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("can't be read")), "{r:?}"); + assert_eq!(captured(&s, bad).as_deref(), Some(broken), "refused: unchanged"); + assert_eq!(captured(&s, other).as_deref(), Some("2026-01-01T12:00:00"), "refused: the others don't move"); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "time": "2026-01-01T11:00:00"})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "alone, it gets the time"); + s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id: bad, captured: Some(broken.into()) }).unwrap(); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00", "each": true})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(2), Some(0)), "{r}"); + assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "each repairs it"); +} + /// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is /// refused with the rule's position, by album.createSmart and library.filter alike. #[test] diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 898faa889..51277da04 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -26,7 +26,13 @@ fn get_set_and_reset() { assert_eq!(g["pageWidth"], 8.5); assert_eq!(g["printable"]["x"], 0.25); assert_eq!(g["settings"]["job"]["ppi"], 240.0); - assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4")); + let papers = g["papers"].as_array().unwrap(); + assert!(papers.iter().any(|p| p["name"] == "a4")); + // The Japanese photo and postcard papers are listed with their metric sizes in inches. + let paper = |name: &str| papers.iter().find(|p| p["name"] == name).cloned().unwrap_or_else(|| panic!("{name}")); + let l = paper("l"); + assert!((l["width"].as_f64().unwrap() - 89.0 / 25.4).abs() < 1e-9 && (l["height"].as_f64().unwrap() - 127.0 / 25.4).abs() < 1e-9); + assert!(papers.iter().any(|p| p["name"] == "postcard") && papers.iter().any(|p| p["name"] == "6cut")); assert_eq!(g["gridCells"].as_array().unwrap().len(), 1); let g = run( @@ -213,6 +219,28 @@ fn export_pdf_prints_the_current_settings() { fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); } +/// The Japanese photo and postcard papers reach the PDF page at their metric sizes: はがき is +/// 100 × 148 mm (283.46 × 419.53 pt) and L is 89 × 127 mm (252.28 × 359.99 pt). 254 ppi is exactly +/// ten pixels per millimetre, so the page pixels are whole millimetres and the MediaBox is exact. +#[test] +fn export_pdf_uses_the_japanese_paper_sizes() { + let mut s = Session::with_demo(); + let id = s.catalog.photos().next().map(|p| p.id.0).unwrap(); + for (paper, w_pt, h_pt) in [("postcard", 283.46, 419.53), ("はがき", 283.46, 419.53), ("l", 252.28, 359.99)] { + run(&mut s, "print.set", json!({"layout": {"style": "singleImage", "page": {"paper": paper}}, "job": {"ppi": 254.0}})); + let path = temp_pdf(paper); + run(&mut s, "print.exportPdf", json!({"path": path, "ids": [id]})); + let bytes = std::fs::read(&path).unwrap(); + let (w, h) = media_box(&bytes); + assert!((w - w_pt).abs() < 0.1 && (h - h_pt).abs() < 0.1, "{paper}: {w} x {h} pt, wanted {w_pt} x {h_pt}"); + } + // The customary names are accepted and the page size follows them; an unknown name is refused. + let g = run(&mut s, "print.set", json!({"layout": {"page": {"paper": "はがき"}}})); + assert!((g["pageWidth"].as_f64().unwrap() - 100.0 / 25.4).abs() < 1e-9); + assert!((g["pageHeight"].as_f64().unwrap() - 148.0 / 25.4).abs() < 1e-9); + fails(&mut s, "print.set", json!({"layout": {"page": {"paper": "poster"}}})); +} + #[test] fn export_pdf_with_a_printer_profile() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/tests_segment.rs b/crates/engine/src/tests_segment.rs index f4b9f4f48..ceb2365fb 100644 --- a/crates/engine/src/tests_segment.rs +++ b/crates/engine/src/tests_segment.rs @@ -77,16 +77,23 @@ fn status_and_model_commands_without_the_model() { #[test] fn a_failing_download_ends_with_an_error_and_never_blocks() { use std::io::{BufRead, BufReader, Write}; - // a local mirror that has nothing + // a local mirror that has nothing; its first answer waits until the test has made its second + // request, so the download is still running then however the threads are scheduled (a 404 at + // once could end it before, and the second request would rightly start a new one) let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}/sam3", l.local_addr().unwrap()); + let (release, released) = std::sync::mpsc::channel::<()>(); std::thread::spawn(move || { + let mut first = Some(released); for mut c in l.incoming().flatten() { let mut r = BufReader::new(c.try_clone().unwrap()); let mut line = String::new(); while r.read_line(&mut line).unwrap_or(0) > 2 { line.clear(); } + if let Some(released) = first.take() { + let _ = released.recv_timeout(Duration::from_secs(10)); + } let _ = c.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 13\r\nConnection: close\r\n\r\n<h1>404</h1>\n"); } }); @@ -103,6 +110,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() { // a second request while it runs starts nothing let again = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap(); assert!(again["started"] == false || again["downloading"] == false); + let _ = release.send(()); let t = Instant::now(); while s.segmenter.download_status().running { assert!(t.elapsed() < Duration::from_secs(30), "the download never ended"); diff --git a/crates/engine/src/tests_view_follow.rs b/crates/engine/src/tests_view_follow.rs index 391e38b3b..bc47d94c9 100644 --- a/crates/engine/src/tests_view_follow.rs +++ b/crates/engine/src/tests_view_follow.rs @@ -1,7 +1,7 @@ //! The view follows a small edit from the list it had ([`Session::visible`]): whatever the edit, the result has to //! be the list worked out from every photo. -use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_catalog::{Album, Op, Photo, PhotoId, Source}; use serde_json::{Value, json}; use crate::Session; @@ -113,6 +113,23 @@ fn edit(s: &mut Session, dice: &mut Dice, ids: &mut Vec<PhotoId>, album: u64) { }; } +fn relative_clock_session(captures: &[&str]) -> (Session, std::sync::Arc<std::sync::Mutex<String>>, Vec<PhotoId>) { + let mut s = Session::new(); + let now = std::sync::Arc::new(std::sync::Mutex::new("2026-10-24T12:00:00".to_string())); + let clock = now.clone(); + s.clock = Box::new(move || clock.lock().unwrap().clone()); + let mut ids = Vec::new(); + for (index, captured) in captures.iter().enumerate() { + let id = PhotoId(index as u64 + 1); + let name = format!("synthetic-{index}.jpg"); + let mut photo = Photo::new(id, Source::Demo { scene: 0 }, &name, "JPEG", 2, 2, "2026-10-01T00:00:00"); + photo.captured = Some((*captured).to_string()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + ids.push(id); + } + (s, now, ids) +} + #[test] fn following_an_edit_gives_the_list_worked_out_from_every_photo() { let (mut s, mut ids, album, smart) = library(240); @@ -161,6 +178,129 @@ fn following_an_edit_gives_the_list_worked_out_from_every_photo() { assert!(s.view_followed > 400, "followed {} times, worked out {} times", s.view_followed, s.view_worked_out); } +#[test] +fn relative_clock_filter_expires_without_catalog_or_history_mutation() { + let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]); + s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}})).unwrap(); + let (before_generation, before) = s.visible_shared(); + assert_eq!(before.as_ref(), ids.as_slice()); + let revision = s.catalog.revision; + let commits = s.commits; + let undo = s.undo.len(); + let redo = s.redo.len(); + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + let (after_generation, after) = s.visible_shared(); + + assert!(after.is_empty(), "a relative filter must expire when its minute changes"); + assert_ne!(after_generation, before_generation, "the changed list gets a new visible generation"); + assert_eq!(s.catalog.revision, revision, "clock expiry is not a catalog edit"); + assert_eq!(s.commits, commits, "clock expiry adds no undo step"); + assert_eq!(s.undo.len(), undo); + assert_eq!(s.redo.len(), redo); +} + +#[test] +fn relative_clock_direct_and_nested_smart_album_sources_expire() { + for nested in [false, true] { + let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]); + let recent = s + .execute( + "album.createSmart", + &json!({"name": "Recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let source = if nested { + s.execute( + "album.createSmart", + &json!({"name": "In Recent", "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": recent}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap() + } else { + recent + }; + s.execute("library.source", &json!({"kind": "album", "id": source})).unwrap(); + assert_eq!(s.visible_cloned(), ids); + let revision = s.catalog.revision; + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + assert!(s.visible_cloned().is_empty(), "nested={nested}"); + assert_eq!(s.catalog.revision, revision, "nested={nested}"); + } +} + +#[test] +fn relative_clock_source_total_expires_before_or_after_visible_refresh() { + for refresh_visible in [false, true] { + let (mut s, now, _) = relative_clock_session(&["2026-10-23T12:00:30"]); + let album = s + .execute( + "album.createSmart", + &json!({"name": "Recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.execute("library.filter", &json!({"text": "synthetic"})).unwrap(); + assert_eq!(s.source_total(), Some(1), "the initial total is computed without asking for visible photos"); + let revision = s.catalog.revision; + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + if refresh_visible { + s.execute("library.refresh", &json!({})).unwrap(); + assert!(s.visible_cloned().is_empty()); + } + assert_eq!(s.source_total(), Some(0), "refresh_visible={refresh_visible}"); + assert_eq!(s.catalog.revision, revision, "refresh_visible={refresh_visible}"); + } +} + +#[test] +fn relative_clock_absolute_and_same_minute_views_keep_their_generations() { + let (mut absolute, absolute_now, _) = relative_clock_session(&["2026-10-23T12:00:30"]); + absolute.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "is", "value": "2026-10-23"}]}})).unwrap(); + let (absolute_generation, absolute_ids) = absolute.visible_shared(); + *absolute_now.lock().unwrap() = "2026-10-25T12:10:00".to_string(); + let (after_absolute_generation, after_absolute_ids) = absolute.visible_shared(); + assert_eq!(after_absolute_generation, absolute_generation); + assert_eq!(after_absolute_ids, absolute_ids); + + let (mut relative, relative_now, _) = relative_clock_session(&["2026-10-23T12:00:30"]); + relative.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}})).unwrap(); + let (relative_generation, relative_ids) = relative.visible_shared(); + *relative_now.lock().unwrap() = "2026-10-24T12:00:45".to_string(); + let (same_minute_generation, same_minute_ids) = relative.visible_shared(); + assert_eq!(same_minute_generation, relative_generation); + assert_eq!(same_minute_ids, relative_ids); +} + +#[test] +fn relative_clock_unrelated_smart_album_does_not_expire_album_filter_view() { + let (mut s, now, ids) = relative_clock_session(&["2026-10-23T12:00:30"]); + let manual = s.catalog.alloc_album_id(); + s.catalog.apply(Op::AddAlbum { album: Album::new(manual, "Manual") }).unwrap(); + s.catalog.apply(Op::SetAlbumPhotos { id: manual, photos: vec![ids[0]] }).unwrap(); + s.execute( + "album.createSmart", + &json!({"name": "Unrelated recent", "rules": {"ruleSet": {"rules": [{"field": "captureDate", "op": "inLast", "value": 1}]}}}), + ) + .unwrap(); + s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": manual.0}]}})).unwrap(); + let (generation, visible) = s.visible_shared(); + assert_eq!(visible.as_ref(), ids.as_slice()); + + *now.lock().unwrap() = "2026-10-24T12:01:00".to_string(); + let (after_generation, after_visible) = s.visible_shared(); + + assert_eq!(after_generation, generation, "an unrelated relative smart album must not expire this view"); + assert_eq!(after_visible, visible); +} + #[test] fn a_view_that_asks_about_the_last_days_follows_the_clock_too() { let (mut s, ids, _, _) = library(60); diff --git a/crates/engine/src/text.rs b/crates/engine/src/text.rs index 12974247f..0e2e39c1e 100644 --- a/crates/engine/src/text.rs +++ b/crates/engine/src/text.rs @@ -1,11 +1,12 @@ //! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter -//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines +//! SemiBold (bundled) first, then the craft-fonts CJK and Thai faces as fallbacks ([`faces`]); glyph outlines //! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out //! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor //! it before drawing; drawing hands out per-pixel coverage, and the caller blends. //! -//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers -//! draw as Inter's missing-glyph box: wrong-looking, never a crash. +//! Horizontal text has no contextual shaping or bidi reordering; Thai combining marks use +//! per-character metrics rather than shaped positioning. Arabic faces are not offered. Characters +//! no face covers draw as Inter's missing-glyph box. use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point}; @@ -16,13 +17,16 @@ pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBo pub const MAX_CHARS: usize = 4000; /// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's -/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is -/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's -/// missing-glyph box. +/// serif look; then any other CJK face), then Thai faces, preferring Regular. Drawn text has no +/// UI language, so every CJK script is offered. Other script faces stay out until their layout is +/// supported. Without craft-fonts that is Inter alone, with missing-glyph boxes for unsupported text. pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec<FontRef<'static>> { let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); cjk.sort_by_key(|f| !f.is_mincho()); - std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() + let thai = crate::fonts::ui_fallback(craft, "Latn", "Regular") + .into_iter() + .filter(|f| f.covers("Thai") && !f.scripts.iter().any(|s| crate::fonts::is_cjk_script(s))); + std::iter::once(TEXT_FONT).chain(cjk.into_iter().chain(thai).map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() } /// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. @@ -223,3 +227,91 @@ impl TextLayout { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::fonts::{CRAFT_FONTS, CraftFont}; + + fn assert_script_glyphs(script: &str, text: &str) { + if !CRAFT_FONTS.iter().any(|font| font.covers(script)) { + lightcraft_testkit::skip!(Fonts, "built without the {script} face from craft-fonts"); + return; + } + let layout = TextLayout::new(text, 40.0, false, CRAFT_FONTS).unwrap(); + assert_eq!(layout.glyphs.len(), text.chars().count()); + for (ch, (face, glyph, _)) in text.chars().zip(&layout.glyphs) { + assert_ne!(glyph.id.0, 0, "missing {script} glyph for {ch}"); + let font = &layout.fonts[*face]; + assert!(CRAFT_FONTS.iter().any(|entry| entry.covers(script) && entry.bytes == font.font_data())); + } + let mut covered = 0; + layout.draw(0.0, 0.0, |_, _, coverage| covered += usize::from(coverage > 0.0)); + assert!(covered > 0, "{script} outlines are rasterized"); + } + + #[test] + fn bundled_arabic_stays_out_of_export_fallback() { + if !CRAFT_FONTS.iter().any(|font| font.covers("Arab")) { + lightcraft_testkit::skip!(Fonts, "built without the Arabic face from craft-fonts"); + return; + } + // UI names still use their existing Arabic face. Export needs joining and bidi first. + assert!(crate::fonts::ui_fallback(CRAFT_FONTS, "Latn", "Regular").iter().any(|font| font.covers("Arab"))); + for vertical in [false, true] { + let layout = TextLayout::new("مرحبا", 40.0, vertical, CRAFT_FONTS).unwrap(); + assert!(layout.glyphs.iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0)); + } + } + + #[test] + fn bundled_thai_uses_its_glyphs() { + assert_script_glyphs("Thai", "สวัสดีครับ"); + } + + #[test] + fn absent_fonts_keep_inter_and_missing_glyph_boxes() { + assert_eq!(faces(&[]).len(), 1); + let layout = TextLayout::new("Aกم", 40.0, false, &[]).unwrap(); + assert_ne!(layout.glyphs[0].1.id.0, 0); + assert!(layout.glyphs[1..].iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0)); + let mut covered = 0; + layout.draw(0.0, 0.0, |_, _, coverage| covered += usize::from(coverage > 0.0)); + assert!(covered > 0); + } + + #[test] + fn latin_and_cjk_rendering_keeps_the_existing_preference() { + let mut cjk: Vec<_> = crate::fonts::cjk(CRAFT_FONTS).collect(); + if cjk.is_empty() { + lightcraft_testkit::skip!(Fonts, "built without CJK faces from craft-fonts"); + return; + } + cjk.sort_by_key(|font| !font.is_mincho()); + let previous: &'static [CraftFont] = Box::leak( + cjk.into_iter() + .map(|font| CraftFont { family: font.family, style: font.style, scripts: font.scripts, bytes: font.bytes }) + .collect::<Vec<_>>() + .into_boxed_slice(), + ); + let actual = faces(CRAFT_FONTS); + assert!(actual.len() > previous.len()); + assert!(actual[0].font_data() == TEXT_FONT, "Inter stays first"); + for (font, expected) in actual.iter().skip(1).zip(previous) { + assert!(font.font_data() == expected.bytes, "existing CJK face order: {} {}", expected.family, expected.style); + } + for vertical in [false, true] { + for text in ["LightCraft AV 123", "日本語、。你好世界한국어"] { + let before = TextLayout::new(text, 40.0, vertical, previous).unwrap(); + let after = TextLayout::new(text, 40.0, vertical, CRAFT_FONTS).unwrap(); + assert_eq!((before.width, before.height), (after.width, after.height)); + let pixels = |layout: &TextLayout| { + let mut pixels = Vec::new(); + layout.draw(0.0, 0.0, |x, y, coverage| pixels.push((x, y, coverage))); + pixels + }; + assert!(pixels(&before) == pixels(&after), "{text}, vertical={vertical}"); + } + } + } +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 0d5cde3cf..dbfb0fade 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -161,11 +161,17 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> { if let Some(fl) = f.flag { add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null})); } - let mut labels = f.labels.clone(); - labels.extend(f.label.filter(|l| !labels.contains(l))); - if !labels.is_empty() { - let names: Vec<String> = labels.iter().map(|l| cat.label_name(*l)).collect(); - add(format!("Label: {}", names.join(" or ")), json!({"label": Null, "labels": []})); + if let Some(shown) = f.labels_shown() { + let or = |ls: &[lightcraft_catalog::ColorLabel]| ls.iter().map(|l| cat.label_name(*l)).collect::<Vec<_>>().join(" or "); + // empty: `label` and `labels` exclude each other, so name both constraints + let names = if shown.is_empty() { + // parenthesised when plural, so "Red and Yellow or Green" cannot read as "(Red and Yellow) or Green" + let plural = if f.labels.len() > 1 { format!("({})", or(&f.labels)) } else { or(&f.labels) }; + format!("{} and {plural}", f.label.map(|l| cat.label_name(l)).unwrap_or_default()) + } else { + or(&shown) + }; + add(format!("Label: {names}"), json!({"label": Null, "labels": []})); } if let Some(k) = f.kind { let n = match k { diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 7efae6a57..452e2dd67 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -450,11 +450,12 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R } /// Whether the GPU renders requests of this kind at all (a given render can still fall back to -/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on -/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it, -/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart. -pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool { - depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange +/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports render on the CPU, and so do +/// soft proofs against an output-device profile (`device_proof`: its 3D table is CPU-side) and +/// Visualize HDR, which needs the float HDR render. [`try_render`] follows it, and the loupe sizes +/// its slider-drag drafts by it (issue #330), so the two can't drift apart. +pub fn handles(depth: lightcraft_pipeline::OutputDepth, device_proof: bool, overlay: lightcraft_pipeline::Overlay) -> bool { + depth == lightcraft_pipeline::OutputDepth::U8 && !device_proof && overlay != lightcraft_pipeline::Overlay::HdrRange } /// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU @@ -473,7 +474,8 @@ pub fn try_render( // sections switched off with their eye render as if at their defaults (issue #316) let effective = s.effective(); let s: &DevelopSettings = &effective; - if !enabled() || !handles(req.depth, req.proof.is_some(), req.overlay) { + let device_proof = req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some()); + if !enabled() || !handles(req.depth, device_proof, req.overlay) { return Err(Fallback::NotTried); } let s = &*lightcraft_pipeline::settings_for(s, req); diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 7f9405492..b46b7cc1a 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -73,6 +73,13 @@ const FIELDS: &[(&str, usize)] = &[ ("OUT_Y", 3), ("OUT_TRC", 1), ("OUT_GAMMA", 1), + // soft proofing (matrix proofs; `ProofParams`): working → proof RGB, its luma, proof → output, warnings + ("PROOF", 1), + ("PROOF_M", 9), + ("PROOF_Y", 3), + ("PROOF_OUT", 9), + ("PROOF_DEST_WARN", 1), + ("PROOF_DISP_WARN", 1), // first row of a band dispatch (the kernel runs over rows Y0.., see `render`) ("Y0", 1), ]; @@ -233,6 +240,14 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr let (trc, gamma) = fp.out_trc.code(); p.u("OUT_TRC", trc); p.f("OUT_GAMMA", gamma); + if let Some(pp) = fp.proof.as_ref().filter(|pp| pp.table.is_none()) { + p.b("PROOF", true); + p.fs("PROOF_M", pp.to_proof.as_flattened()); + p.fs("PROOF_Y", &pp.luma); + p.fs("PROOF_OUT", pp.proof_to_out.as_flattened()); + p.b("PROOF_DEST_WARN", pp.dest_warning); + p.b("PROOF_DISP_WARN", pp.display_warning); + } if let Some((amt, cell, rough, seed)) = fp.grain { p.b("GRAIN", true); p.f("GRAIN_AMT", amt); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 0c5bd19f0..64990db0e 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -669,8 +669,10 @@ pub fn render( // 5. per-pixel stage let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + // soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`) + fp.proof = req.proof.map(|pr| pr.params(req.space)); if let Some(d) = &req.display { - fp.for_display(d, None); + fp.for_display(d, req.proof); } let present = Present { clarity: prep.clarity.is_some(), diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index d51308ee0..04115b945 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -252,6 +252,46 @@ fn out_encode(v: f32) -> f32 { return 1.099 * pow(l, 0.45) - 0.099; } +// The 3×3 matrix stored row by row from field `o`. +fn mat_at(o: u32) -> array<vec3<f32>, 3> { + return array<vec3<f32>, 3>( + vec3<f32>(pf(o), pf(o + 1u), pf(o + 2u)), + vec3<f32>(pf(o + 3u), pf(o + 4u), pf(o + 5u)), + vec3<f32>(pf(o + 6u), pf(o + 7u), pf(o + 8u)), + ); +} + +// `finish::gamut_map` (SDR): the chroma scale that brings `r` into 0..1 towards its luminance (1: in gamut) ... +fn gamut_scale(r: vec3<f32>, luma: vec3<f32>) -> f32 { + let yy = clamp(dot(luma, r), 0.0, 1.0); + var tg = 1.0; + for (var k = 0u; k < 3u; k++) { + let cc = r[k]; + if (cc < 0.0) { + tg = min(tg, yy / max(yy - cc, 1e-9)); + } else if (cc > 1.0) { + tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9)); + } + } + return tg; +} + +// ... and `r` scaled by it. +fn gamut_apply(r: vec3<f32>, luma: vec3<f32>, tg: f32) -> vec3<f32> { + if (tg < 1.0) { + let yy = clamp(dot(luma, r), 0.0, 1.0); + return yy + (r - yy) * tg; + } + return r; +} + +// `finish::out_of_gamut`: `r` needed desaturating by `t` to fit and isn't just a neutral beyond white or below black. +fn out_of_gamut(r: vec3<f32>, t: f32) -> bool { + let lo = min(r.x, min(r.y, r.z)); + let hi = max(r.x, max(r.y, r.z)); + return t < 0.995 && (hi - lo) > 0.02 * max(abs(hi), 1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0)); +} + @compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) gid: vec3<u32>) { let w = pu(F_W); @@ -324,8 +364,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) { // --- dehaze (scene linear) let dz = pf(F_DEHAZE) + lt[10]; if (dz != 0.0 && pu(F_HAS_DARK) != 0u) { - let d = clamp(dark[i] / pf(F_AIR_PRE), 0.0, 1.0); + // the dark channel is a blur, not a patch minimum: never attribute more veil than the + // pixel's own minimum channel, or dark pixels next to bright ones crush to black (#2504) let air = pf(F_AIR); + let cmin = min(c.x, min(c.y, c.z)); + let d = clamp(min(select(0.0, dark[i] / pf(F_AIR_PRE), pf(F_AIR_PRE) > 0.0), select(0.0, cmin / air, air > 0.0)), 0.0, 1.0); if (dz > 0.0) { let t = max(1.0 - 0.95 * min(dz, 1.0) * d, 0.12); c = max((c - air * (1.0 - t)) / t, vec3<f32>(0.0)); @@ -467,26 +510,27 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) { } } - // --- gamut map to the output space (desaturate towards luminance until in range) - let om = array<vec3<f32>, 3>( - vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)), - vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)), - vec3<f32>(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)), - ); - var r = mul3(om, d); - let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0); - var tg = 1.0; - for (var k = 0u; k < 3u; k++) { - let cc = r[k]; - if (cc < 0.0) { - tg = min(tg, yy / max(yy - cc, 1e-9)); - } else if (cc > 1.0) { - tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9)); + // --- gamut map to the output space (desaturate towards luminance until in range); + // soft proofing maps into the proof space first and shows that in the output space + // (warn: 0 none, 1 destination gamut, 2 display gamut) + var warn = 0u; + var r: vec3<f32>; + if (pu(F_PROOF) != 0u) { + let q0 = mul3(mat_at(F_PROOF_M), d); + let tq = gamut_scale(q0, vec3<f32>(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u))); + if (pu(F_PROOF_DEST_WARN) != 0u && out_of_gamut(q0, tq)) { + warn = 1u; } + r = mul3(mat_at(F_PROOF_OUT), gamut_apply(q0, vec3<f32>(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)), tq)); + } else { + r = mul3(mat_at(F_OUT_M), d); } - if (tg < 1.0) { - r = yy + (r - yy) * tg; + let oy = vec3<f32>(pf(F_OUT_Y), pf(F_OUT_Y + 1u), pf(F_OUT_Y + 2u)); + let tg = gamut_scale(r, oy); + if (pu(F_PROOF) != 0u && pu(F_PROOF_DISP_WARN) != 0u && warn == 0u && out_of_gamut(r, tg)) { + warn = 2u; } + r = gamut_apply(r, oy, tg); // --- encode, curves, grain var e = vec3<f32>(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z)); @@ -512,6 +556,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) { let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e + k; } + if (warn == 1u) { + e = vec3<f32>(1.0, 0.0, 0.0); + } else if (warn == 2u) { + e = vec3<f32>(0.0, 0.25, 1.0); + } e = vec3<f32>(out_encode(e.x), out_encode(e.y), out_encode(e.z)); out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u); } diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index c877e3d9d..6a8169b30 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -79,6 +79,19 @@ fn check(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, (mean, max) } +/// [`check`] for gamut-warning renders: a warning is a hard decision at the gamut boundary, so a +/// pixel sitting exactly on it may be painted by one renderer and not the other (float rounding). +/// Such flips are allowed for at most 0.01 % of the pixels; everything else is held to [`check`]'s bounds. +fn check_warned(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) { + let cpu = render(src, info, s, req).image; + let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render"); + let (mean, _, over) = diff(&cpu, &gpu.image); + let flips = cpu.data.iter().zip(&gpu.image.data).filter(|(p, q)| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB)).count(); + let share = flips as f64 / cpu.data.len() as f64; + eprintln!("{name:<28} {}x{} mean {mean:.4} >1: {:.4}% boundary flips {flips}", cpu.width, cpu.height, over * 100.0); + assert!(mean < MEAN_LSB && share <= 1e-4, "{name}: mean {mean:.4} LSB, {flips} pixels differ by more than {MAX_LSB} LSB"); +} + type Edit = fn(&mut DevelopSettings); fn typical(s: &mut DevelopSettings) { @@ -877,3 +890,54 @@ fn windows_of_a_big_photo_share_one_uploaded_source() { lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render"); assert_eq!(lightcraft_gpu::source_uploads() - start, 2); } + +#[test] +fn soft_proofs_match() { + if !gpu() { + return; + } + // Soft proofing against an output space (matrix proofs): into the proof gamut, back to the + // output space, with the destination and display gamut warnings painted alike on both renderers. + // Saturated edits put plenty of colours outside the smaller proof spaces. + use lightcraft_pipeline::{OutputSpace, Proof}; + let src = scene(0, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + for space in [OutputSpace::Srgb, OutputSpace::AdobeRgb, OutputSpace::DisplayP3] { + for (dest_warning, display_warning) in [(false, false), (true, false), (false, true), (true, true)] { + for out in [OutputSpace::Srgb, OutputSpace::Rec2020] { + let mut s = DevelopSettings::default(); + saturated(&mut s); + let proof = Some(Proof { space, dest_warning, display_warning, profile: 0 }); + let req = RenderRequest { space: out, proof, ..RenderRequest::fit(640, 640) }; + let name = format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}"); + if dest_warning || display_warning { + check_warned(&name, &src, &raw, &s, &req); + } else { + check(&name, &src, &raw, &s, &req); + } + } + } + } + // the case is meaningful: proofing sRGB from Rec. 2020 output warns on some pixels, and the GPU paints the same ones + let mut s = DevelopSettings::default(); + saturated(&mut s); + let req = RenderRequest { + space: OutputSpace::Rec2020, + proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: 0 }), + ..RenderRequest::fit(640, 640) + }; + let warned = |i: &Rgba8| i.data.iter().filter(|p| p[1] == 0 && p[2] == 0 && p[0] > 200).count(); + let (cpu, gpu) = (render(&src, &raw, &s, &req).image, lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu render").image); + assert!(warned(&cpu) > 100, "{} warned pixels", warned(&cpu)); + let (a, b) = (warned(&cpu), warned(&gpu)); + assert!(a.abs_diff(b) as f64 <= 1e-4 * cpu.data.len() as f64, "warned pixels: CPU {a}, GPU {b}"); + // an output-device profile (a 3D table) stays on the CPU + let n = 2; + let lut = lightcraft_pipeline::ProofLut::new(n, vec![[0.5, 0.5, 0.5, 0.0]; n * n * n]).unwrap(); + let id = lightcraft_pipeline::register_proof_profile(0x5eed_9f00, lut); + let req = RenderRequest { + proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: id }), + ..RenderRequest::fit(64, 64) + }; + assert!(lightcraft_gpu::render(&src, &raw, &DevelopSettings::default(), &req, None).is_none(), "declined: the CPU renders it"); +} diff --git a/crates/merge/src/align.rs b/crates/merge/src/align.rs index 34a606155..be103c4e5 100644 --- a/crates/merge/src/align.rs +++ b/crates/merge/src/align.rs @@ -5,7 +5,6 @@ use lightcraft_geom::{Homography, Point}; use lightcraft_raster::resample::{Filter, fit}; use lightcraft_raster::{Plane, Rgb32f}; -use rayon::prelude::*; use crate::features::{self, Features}; use crate::ransac::{self, Model}; @@ -129,23 +128,10 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz /// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped /// image and a validity mask (false where `h(p)` falls outside `img`). pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec<bool>) { - let (w, ht) = (img.width, img.height); if *h == Homography::IDENTITY { - return (img.clone(), vec![true; w * ht]); + return (img.clone(), vec![true; img.width * img.height]); } - let mut out = Rgb32f::new(w, ht); - let mut valid = vec![false; w * ht]; - out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| { - for x in 0..w { - let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let (sx, sy) = (p.x as f32, p.y as f32); - if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 { - row[x] = img.sample_lanczos3(sx, sy); - vrow[x] = true; - } - } - }); - (out, valid) + crate::flow::warp(img, h, &crate::flow::Flow::default()) } #[cfg(test)] diff --git a/crates/merge/src/flow.rs b/crates/merge/src/flow.rs new file mode 100644 index 000000000..a8a9a6bf9 --- /dev/null +++ b/crates/merge/src/flow.rs @@ -0,0 +1,239 @@ +//! Local alignment: what one motion model for the whole frame leaves. +//! +//! A homography is exact only for a camera that turned about its lens. Handheld, the camera also +//! moves, and things at different distances shift differently (parallax): on a real bracket (Sony, +//! 33 MP, gravel a few metres away and a building a kilometre off) the fit, dominated by the +//! foreground's texture, left the building 2.3 px off, and the merge doubled its inscription. Lens +//! distortion and rolling shutter leave local shifts too. +//! +//! After the global fit ([`crate::align`], [`crate::refine`]) the frame is compared with the +//! reference tile by tile: +//! +//! 1. **Measure.** On a grid of about [`GRID`] tiles along the long edge, the reference's patch at +//! the tile's centre is found in the frame, through the global fit, within [`RADIUS`] pixels +//! ([`crate::refine::match_patch`]: masked correlation, sub-pixel peak, ambiguous and textureless +//! patches dropped). +//! 2. **Clean.** A shift more than [`OUTLIER`] px from the median of its measured neighbours is +//! dropped (a person walking, a mismatch), as is one with no measured neighbour to check it by. Tiles without a measurement take the mean of their +//! measured neighbours, spreading outwards until every tile has one. +//! 3. **Smooth** with two 3 × 3 box passes, so the field bends gently. +//! 4. **Warp.** Each output pixel `p` samples the frame at `h(p + flow(p))` (the flow interpolated +//! bilinearly between tile centres): one Lanczos-3 resampling for both corrections. + +use lightcraft_geom::{Homography, Point}; +use lightcraft_raster::{Plane, Rgb32f}; +use rayon::prelude::*; + +use crate::refine::{self, PATCH}; + +/// Tiles along the long edge. +pub const GRID: usize = 96; +/// Largest local shift measured (pixels); farther ones are filled from neighbours. +pub const RADIUS: usize = 5; +/// A tile this far (pixels) from the median of its neighbours is an outlier. +pub const OUTLIER: f32 = 0.75; + +/// A smooth field of local shifts in reference pixels: one value per tile, tile `(i, j)`'s at +/// `((i + ½)·step, (j + ½)·step)`, so pixel centres of `dx` / `dy` line up with the tiles'. +#[derive(Clone, Debug, PartialEq)] +pub struct Flow { + pub step: usize, + pub dx: Plane, + pub dy: Plane, + /// Tiles that were measured (before filling). + pub measured: usize, +} + +impl Default for Flow { + /// No shift anywhere. + fn default() -> Self { + Flow { step: 0, dx: Plane::new(0, 0), dy: Plane::new(0, 0), measured: 0 } + } +} + +impl Flow { + /// The shift at reference coordinates (pixel centres at +0.5), bilinear between tile centres; + /// zero for an empty flow. + pub fn at(&self, x: f64, y: f64) -> (f64, f64) { + let (fx, fy) = ((x / self.step as f64) as f32, (y / self.step as f64) as f32); + if self.dx.data.is_empty() || !fx.is_finite() || !fy.is_finite() { + return (0.0, 0.0); + } + (crate::features::bilinear(&self.dx, fx, fy) as f64, crate::features::bilinear(&self.dy, fx, fy) as f64) + } + + /// The largest shift in the field (pixels). + pub fn max(&self) -> f32 { + self.dx.data.iter().zip(&self.dy.data).map(|(x, y)| x.hypot(*y)).fold(0.0, f32::max) + } +} + +/// The local shifts between `reference` and `frame` (both from [`refine::luma`]) once `h` (reference +/// → frame pixels) is applied, within `radius` pixels. +pub fn estimate(reference: &Plane, frame: &Plane, h: &Homography, radius: usize) -> Flow { + let (w, ht) = (reference.width, reference.height); + // an even step, so a patch centred on a tile has a whole-pixel corner + let step = ((w.max(ht) / GRID).max(PATCH) + 1) & !1; + let (gw, gh) = (w / step, ht / step); + if gw == 0 || gh == 0 || frame.data.is_empty() { + return Flow::default(); + } + let r = radius.max(1); + let raw: Vec<Option<[f32; 2]>> = (0..gw * gh) + .into_par_iter() + .map(|t| { + let (i, j) = (t % gw, t / gw); + let (sx, sy) = (i * step + (step - PATCH) / 2, j * step + (step - PATCH) / 2); + if sx < r + 1 || sy < r + 1 || sx + PATCH + r + 1 > w || sy + PATCH + r + 1 > ht { + return None; + } + refine::match_patch(reference, (sx, sy), frame, h, r).map(|m| [m.offset.x as f32, m.offset.y as f32]) + }) + .collect(); + let measured = raw.iter().filter(|v| v.is_some()).count(); + let at = |d: &[Option<[f32; 2]>], k: usize| d.get(k).copied().flatten(); + // 2. outliers against the median of their measured neighbours + let mut d: Vec<Option<[f32; 2]>> = (0..gw * gh) + .map(|t| { + let v = at(&raw, t)?; + let n = neighbours(|k| at(&raw, k), gw, gh, t); + let med = [median(n.iter().map(|v| v[0]).collect()), median(n.iter().map(|v| v[1]).collect())]; + // a tile with no measured neighbour can't be checked, and the fill would spread it: dropped + (!n.is_empty() && (v[0] - med[0]).hypot(v[1] - med[1]) <= OUTLIER).then_some(v) + }) + .collect(); + // fill outwards from measured tiles + for _ in 0..gw + gh { + if d.iter().all(Option::is_some) || d.iter().all(Option::is_none) { + break; + } + let prev = d.clone(); + for (t, slot) in d.iter_mut().enumerate().filter(|(_, s)| s.is_none()) { + *slot = mean(&neighbours(|k| at(&prev, k), gw, gh, t)); + } + } + let mut f: Vec<[f32; 2]> = d.into_iter().map(|v| v.unwrap_or([0.0; 2])).collect(); + // 3. smooth (each tile with its neighbours) + for _ in 0..2 { + let prev = f.clone(); + for (t, v) in f.iter_mut().enumerate() { + let mut n = neighbours(|k| prev.get(k).copied(), gw, gh, t); + n.push(*v); + *v = mean(&n).unwrap_or(*v); + } + } + let plane = |c: usize| Plane::from_fn(gw, gh, |i, j| f.get(j * gw + i).map_or(0.0, |v| v[c])); + Flow { step, dx: plane(0), dy: plane(1), measured } +} + +/// The values `get` has among the 8 neighbours of tile `t`. +fn neighbours(get: impl Fn(usize) -> Option<[f32; 2]>, gw: usize, gh: usize, t: usize) -> Vec<[f32; 2]> { + let (i, j) = (t % gw, t / gw); + let mut out = Vec::with_capacity(8); + for b in j.saturating_sub(1)..=(j + 1).min(gh - 1) { + for a in i.saturating_sub(1)..=(i + 1).min(gw - 1) { + if (a, b) != (i, j) + && let Some(v) = get(b * gw + a) + { + out.push(v); + } + } + } + out +} + +fn mean(n: &[[f32; 2]]) -> Option<[f32; 2]> { + let k = n.len() as f32; + (!n.is_empty()).then(|| [n.iter().map(|v| v[0]).sum::<f32>() / k, n.iter().map(|v| v[1]).sum::<f32>() / k]) +} + +fn median(mut v: Vec<f32>) -> f32 { + v.sort_by(|a, b| a.total_cmp(b)); + v.get(v.len() / 2).copied().unwrap_or(0.0) +} + +/// `img` resampled into the reference's geometry through `h` after the local shifts: +/// `out(p) = img(h(p + flow(p)))` (Lanczos-3), and where that falls inside `img`. +pub fn warp(img: &Rgb32f, h: &Homography, flow: &Flow) -> (Rgb32f, Vec<bool>) { + let (w, ht) = (img.width, img.height); + let mut out = Rgb32f::new(w, ht); + let mut valid = vec![false; w * ht]; + if w == 0 || ht == 0 { + return (out, valid); + } + out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| { + for x in 0..w { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let (dx, dy) = flow.at(px, py); + let q = h.apply(Point::new(px + dx, py + dy)); + let (sx, sy) = (q.x as f32, q.y as f32); + if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 { + row[x] = img.sample_lanczos3(sx, sy); + vrow[x] = true; + } + } + }); + (out, valid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::refine::tests::tex; + + /// Parallax: the top of the frame (far) shifted 2.2 px against the bottom (near), with a + /// smooth band between. The flow finds both, and the warp lines the frame up everywhere. + #[test] + fn finds_and_undoes_a_local_shift() { + let (w, h) = (1200usize, 800usize); + let shift = |y: f64| { + let t = ((y - 300.0) / 200.0).clamp(0.0, 1.0); + let s = t * t * (3.0 - 2.0 * t); + ((1.0 - s) * 2.2, (1.0 - s) * -1.1) + }; + let reference = Plane::from_fn(w, h, |x, y| tex(x as f64 + 0.5, y as f64 + 0.5)); + // the frame's pixel q shows the scene at q + shift(q.y) + let frame = Rgb32f::from_fn(w, h, |x, y| { + let (sx, sy) = shift(y as f64 + 0.5); + [tex(x as f64 + 0.5 + sx, y as f64 + 0.5 + sy); 3] + }); + let fl = refine::luma(&frame, 1.0, 10.0); + let flow = estimate(&reference, &fl, &Homography::IDENTITY, RADIUS); + let tiles = flow.dx.width * flow.dx.height; + assert!(flow.measured > tiles / 2, "{} of {tiles}", flow.measured); + // reference p matches frame p + d with d = −shift + let (tx, ty) = flow.at(600.0, 100.0); + assert!((tx + 2.2).abs() < 0.15 && (ty - 1.1).abs() < 0.15, "top {tx} {ty}"); + let (bx, by) = flow.at(600.0, 700.0); + assert!(bx.abs() < 0.15 && by.abs() < 0.15, "bottom {bx} {by}"); + let (out, valid) = warp(&frame, &Homography::IDENTITY, &flow); + let error = |img: &Rgb32f| { + let (mut e, mut n) = (0f64, 0usize); + for y in (40..h - 40).step_by(3) { + for x in (40..w - 40).step_by(3) { + if valid[y * w + x] { + e += (img.get(x, y)[0] - reference.get(x, y)).abs() as f64; + n += 1; + } + } + } + e / n as f64 + }; + let (before, after) = (error(&frame), error(&out)); + assert!(after < before / 6.0, "mean error {before} unwarped, {after} warped"); + } + + #[test] + fn empty_and_flat_inputs_give_no_shift() { + let flat = Plane::from_fn(400, 300, |_, _| 0.5); + let f = estimate(&flat, &flat, &Homography::IDENTITY, RADIUS); + assert_eq!(f.measured, 0); + assert_eq!(f.max(), 0.0); + assert_eq!(estimate(&Plane::new(0, 0), &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default()); + assert_eq!(estimate(&flat, &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default()); + assert_eq!(Flow::default().at(5.0, f64::NAN), (0.0, 0.0)); + assert_eq!(f.at(f64::INFINITY, 3.0), (0.0, 0.0)); + let (img, v) = warp(&Rgb32f::new(0, 0), &Homography::IDENTITY, &Flow::default()); + assert!(img.data.is_empty() && v.is_empty()); + } +} diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index 4b4e206e6..b5ccd4bb8 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -15,12 +15,14 @@ pub mod align; pub mod features; +pub mod flow; pub mod frame; pub mod hdr; pub mod linalg; pub mod output; pub mod pano; pub mod ransac; +pub mod refine; pub mod synth; pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with}; diff --git a/crates/merge/src/refine.rs b/crates/merge/src/refine.rs new file mode 100644 index 000000000..e9044bc8b --- /dev/null +++ b/crates/merge/src/refine.rs @@ -0,0 +1,359 @@ +//! Full-resolution refinement of an alignment found on small versions of the frames. +//! +//! [`crate::align`] registers frames on versions of at most [`crate::align::WORK_EDGE`] pixels. +//! For a 24–60 MP photo that is 5–8× smaller, and a tenth of a work pixel off is most of a full +//! pixel off: merged frames that far apart blur the result. Here the motion is fitted again at +//! full size: +//! +//! 1. **Sites.** The reference is cut into a grid of cells (≈ [`CELLS`] along the long edge); in +//! each, the [`PATCH`]-pixel square with the most two-dimensional texture (the smaller +//! eigenvalue of its gradient structure tensor, so a straight edge, which only fixes one +//! direction, doesn't count) is a site. +//! 2. **Matching.** Each site's patch is compared with the frame resampled through the current +//! alignment, by normalised cross-correlation (immune to the frames' different brightness and +//! contrast) over integer offsets within a search radius, counting only pixels usable in both +//! (clipped ones aren't); the peak is refined to a fraction of a pixel by a parabola through its +//! neighbours on each axis. Patches less than half usable, with too little texture, a weak peak, a peak on the search border or a second peak almost +//! as strong (repeating texture) are dropped. +//! 3. **Fit.** The matches are fitted with RANSAC at [`THRESH`] pixels, choosing the simplest of +//! translation / similarity / homography that explains (nearly) as many as the most general +//! one, as [`crate::align::align_pair`] does. A second, narrower pass starts from that fit. +//! +//! When too few patches match, or the fit isn't a plausible handheld motion, the alignment found +//! on the small versions stays: refining never makes an alignment worse than not refining. + +use lightcraft_geom::{Homography, Point}; +use lightcraft_raster::{Plane, Rgb32f}; +use rayon::prelude::*; + +use crate::align::{Alignment, Models, choose_model, luma as lum}; +use crate::ransac::Model; + +/// Side of a matched patch (pixels). +pub const PATCH: usize = 48; +/// Cells along the long edge (sites are picked one per cell). +pub const CELLS: usize = 24; +/// RANSAC inlier threshold at full resolution (pixels). +pub const THRESH: f64 = 0.75; +/// Weakest correlation peak a match may have. +const MIN_SCORE: f32 = 0.8; +/// The best peak away from the best one must be at least this much weaker. +const UNIQUE: f32 = 0.03; +/// Least standard deviation (of the gamma-encoded luminance) a patch needs to be matched. +const MIN_STD: f32 = 0.01; +/// Fewest matches a fit is made from. +const MIN_MATCHES: usize = 12; +/// Share of the matches the fit must explain. +const MIN_SHARE: f64 = 0.4; + +/// A frame's luminance for matching: `(luminance · gain)^(1/2.2)` at full size, NaN where the +/// frame is clipped (any channel at or above 95 % of `clip`) or `gain` takes it past 1, so such +/// pixels never take part. `gain` brings frames of a bracket to the same brightness. +pub fn luma(img: &Rgb32f, gain: f32, clip: f32) -> Plane { + let g = if gain.is_finite() && gain > 0.0 { gain } else { 1.0 }; + img.map(|c| { + if c[0].max(c[1]).max(c[2]) >= 0.95 * clip { + return f32::NAN; + } + let y = lum(c) * g; + if y.is_finite() && y < 1.0 { y.max(0.0).powf(1.0 / 2.2) } else { f32::NAN } + }) +} + +/// The search radius (full-resolution pixels) for an alignment found at `scale` (work pixels per +/// full pixel): about two work pixels. +pub fn radius_for(scale: f64) -> usize { + if scale.is_finite() && scale > 0.0 { ((2.0 / scale).ceil() as usize + 1).clamp(3, 16) } else { 3 } +} + +/// A patch of the reference found in a frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Match { + /// Centre of the patch in the reference (pixel centres at +0.5). + pub reference: Point, + /// Where it is in the frame. + pub frame: Point, + /// The offset from where the alignment put it, in reference pixels. + pub offset: Point, + /// Correlation at the peak (−1..1). + pub score: f32, +} + +/// Top-left corners of the patches to match: one per cell, where the reference has the most +/// two-dimensional texture, `margin` pixels clear of the edges. +pub fn sites(reference: &Plane, margin: usize) -> Vec<(usize, usize)> { + let (w, h) = (reference.width, reference.height); + let need = PATCH + 2 * margin; + if w < need || h < need { + return Vec::new(); + } + let long = w.max(h); + let cell = long.div_ceil(CELLS).max(PATCH); + let (nx, ny) = ((w - 2 * margin) / cell, (h - 2 * margin) / cell); + let (nx, ny) = (nx.max(1), ny.max(1)); + let (cw, chh) = ((w - 2 * margin) / nx, (h - 2 * margin) / ny); + (0..nx * ny) + .into_par_iter() + .filter_map(|c| { + let (cx, cy) = (margin + (c % nx) * cw, margin + (c / nx) * chh); + let span_x = cw.saturating_sub(PATCH); + let span_y = chh.saturating_sub(PATCH); + let mut best: Option<((usize, usize), f32)> = None; + for j in 0..3 { + for i in 0..3 { + let (x, y) = (cx + span_x * i / 2, cy + span_y * j / 2); + if x + PATCH > w - margin || y + PATCH > h - margin { + continue; + } + if let Some(t) = texture(reference, x, y) + && best.is_none_or(|(_, b)| t > b) + { + best = Some(((x, y), t)); + } + } + } + best.filter(|(_, t)| *t > 1e-6).map(|(p, _)| p) + }) + .collect() +} + +/// The smaller eigenvalue of the patch's mean gradient structure tensor, over its usable pixels; +/// `None` when fewer than half of them are. +fn texture(p: &Plane, x0: usize, y0: usize) -> Option<f32> { + let w = p.width; + let at = |x: usize, y: usize| p.data.get(y * w + x).copied().unwrap_or(f32::NAN); + let (mut sxx, mut syy, mut sxy, mut n) = (0f64, 0f64, 0f64, 0usize); + for y in y0 + 1..y0 + PATCH - 1 { + for x in x0 + 1..x0 + PATCH - 1 { + let gx = (at(x + 1, y) - at(x - 1, y)) as f64; + let gy = (at(x, y + 1) - at(x, y - 1)) as f64; + if gx.is_finite() && gy.is_finite() { + sxx += gx * gx; + syy += gy * gy; + sxy += gx * gy; + n += 1; + } + } + } + if n * 2 < (PATCH - 2) * (PATCH - 2) { + return None; + } + let n = n as f64; + let (a, b, c) = (sxx / n, syy / n, sxy / n); + let tr = a + b; + let det = a * b - c * c; + Some((tr / 2.0 - ((tr * tr / 4.0 - det).max(0.0)).sqrt()) as f32) +} + +/// Find the reference patch at `site` (top-left) in `frame`, which `h` maps reference pixels into, +/// searching offsets up to `radius` pixels. +pub fn match_patch(reference: &Plane, site: (usize, usize), frame: &Plane, h: &Homography, radius: usize) -> Option<Match> { + let (sx, sy) = site; + let (rw, rh) = (reference.width, reference.height); + if sx + PATCH > rw || sy + PATCH > rh || frame.width == 0 || frame.height == 0 { + return None; + } + let r = radius.max(1); + let s = PATCH + 2 * r; + let mut pr: Vec<f32> = Vec::with_capacity(PATCH * PATCH); + for y in sy..sy + PATCH { + for x in sx..sx + PATCH { + pr.push(reference.data.get(y * rw + x).copied().unwrap_or(f32::NAN)); + } + } + // the frame around the patch, through `h`; NaN where it can't be used + let (fw, fh) = (frame.width as f64, frame.height as f64); + let mut win = vec![f32::NAN; s * s]; + for j in 0..s { + for i in 0..s { + let q = h.apply(Point::new((sx + i) as f64 - r as f64 + 0.5, (sy + j) as f64 - r as f64 + 0.5)); + if !(q.x >= 0.5 && q.y >= 0.5 && q.x <= fw - 0.5 && q.y <= fh - 0.5) { + return None; + } + win[j * s + i] = crate::features::bilinear(frame, q.x as f32, q.y as f32); + } + } + let d = 2 * r + 1; + // offsets that can't be scored (too few usable pixels) stay at −∞ + let mut score = vec![f32::NEG_INFINITY; d * d]; + for dy in 0..d { + for dx in 0..d { + if let Some(c) = correlation(&pr, |u, v| win.get((v + dy) * s + dx + u).copied().unwrap_or(f32::NAN)) { + score[dy * d + dx] = c; + } + } + } + let (bi, &best) = score.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1))?; + let (bx, by) = (bi % d, bi / d); + if best < MIN_SCORE || bx == 0 || by == 0 || bx == d - 1 || by == d - 1 { + return None; + } + // a second peak nearly as strong: repeating texture, the match is ambiguous + let second = + score.iter().enumerate().filter(|(k, _)| (k % d).abs_diff(bx) > 1 || (k / d).abs_diff(by) > 1).map(|(_, v)| *v).fold(-1f32, f32::max); + if second > best - UNIQUE { + return None; + } + let at = |x: usize, y: usize| score[y * d + x]; + // the sub-pixel parabola needs the peak's four neighbours: one that couldn't be scored would pull it away + if [at(bx - 1, by), at(bx + 1, by), at(bx, by - 1), at(bx, by + 1)].iter().any(|v| !v.is_finite()) { + return None; + } + let sub = |m: f32, c: f32, p: f32| { + let den = m - 2.0 * c + p; + if den < -1e-9 { (0.5 * (m - p) / den).clamp(-0.5, 0.5) } else { 0.0 } + }; + let ox = bx as f64 - r as f64 + sub(at(bx - 1, by), best, at(bx + 1, by)) as f64; + let oy = by as f64 - r as f64 + sub(at(bx, by - 1), best, at(bx, by + 1)) as f64; + let c = Point::new(sx as f64 + PATCH as f64 / 2.0, sy as f64 + PATCH as f64 / 2.0); + Some(Match { reference: c, frame: h.apply(Point::new(c.x + ox, c.y + oy)), offset: Point::new(ox, oy), score: best }) +} + +/// Correlation of the reference patch `a` (row-major, [`PATCH`] wide) with `b(u, v)` over the +/// pixels usable in both; `None` with fewer than half of the patch usable, or too little texture in +/// either. +fn correlation(a: &[f32], b: impl Fn(usize, usize) -> f32) -> Option<f32> { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0usize, 0f64, 0f64, 0f64, 0f64, 0f64); + for v in 0..PATCH { + for u in 0..PATCH { + let (x, y) = (a.get(v * PATCH + u).copied().unwrap_or(f32::NAN), b(u, v)); + if x.is_finite() && y.is_finite() { + let (x, y) = (x as f64, y as f64); + n += 1; + sa += x; + sb += y; + saa += x * x; + sbb += y * y; + sab += x * y; + } + } + } + if n * 2 < PATCH * PATCH { + return None; + } + let nf = n as f64; + let (ma, mb) = (sa / nf, sb / nf); + let (va, vb) = (saa / nf - ma * ma, sbb / nf - mb * mb); + let min = (MIN_STD as f64).powi(2); + if !(va >= min && vb >= min * 0.25) { + return None; + } + let c = (sab / nf - ma * mb) / (va * vb).sqrt(); + c.is_finite().then_some(c as f32) +} + +/// Translation, similarity and homography: what a handheld frame may need. +pub const ALL_MODELS: [(Model, &str, usize); 3] = + [(Model::Translation, "translation", 6), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 12)]; + +/// Search radius (pixels) for a frame with no alignment to start from (the small versions found +/// none: a bright frame mostly clipped, too little texture left for features). +pub const RECOVER_RADIUS: usize = 16; + +/// Fit `models` (reference → frame, full resolution) to patches of `reference` matched in `frame` +/// around `start` within `radius` pixels, then again within 2 px of that fit; both planes from +/// [`luma`]. `start` is an alignment found on small versions (`radius` from [`radius_for`]), or the +/// identity with [`RECOVER_RADIUS`]. `None` when too few patches match or no fit is a plausible +/// handheld motion: the caller keeps what it had. +pub fn fit(reference: &Plane, frame: &Plane, start: &Homography, radius: usize, seed: u64, models: &Models) -> Option<Alignment> { + let (w, ht) = (reference.width as f64, reference.height as f64); + let r0 = radius.max(2); + // the sites for the widest search fit the narrower one too + let s = sites(reference, r0 + 2); + let mut cur: Option<Alignment> = None; + for r in [r0, 2] { + let h = cur.as_ref().map_or(*start, |a| a.h); + let m: Vec<Match> = s.par_iter().filter_map(|&p| match_patch(reference, p, frame, &h, r)).collect(); + if m.len() < MIN_MATCHES { + break; + } + let pa: Vec<Point> = m.iter().map(|m| m.reference).collect(); + let pb: Vec<Point> = m.iter().map(|m| m.frame).collect(); + let Some((name, f)) = choose_model(models, &pa, &pb, THRESH, MIN_SHARE, (w, ht), seed) else { break }; + cur = Some(Alignment { h: f.h, model: name, inliers: f.inliers.len(), rms: f.rms }); + } + cur +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A smooth random texture, sampled anywhere. + pub(crate) fn tex(x: f64, y: f64) -> f32 { + let mut v = 0.45; + for k in 0..9 { + let a = k as f64 * 2.399; + let f = 0.02 + 0.025 * k as f64; + v += 0.05 * ((x * a.cos() + y * a.sin()) * f * std::f64::consts::TAU + k as f64).sin(); + } + v as f32 + } + + fn plane(w: usize, h: usize, map: &Homography) -> Plane { + Plane::from_fn(w, h, |x, y| { + let p = map.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + tex(p.x, p.y) + }) + } + + fn corner_error(a: &Homography, b: &Homography, w: f64, h: f64) -> f64 { + [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| a.apply(Point::new(x, y)).dist(b.apply(Point::new(x, y)))).fold(0.0, f64::max) + } + + /// A frame rotated 0.2° and moved 1.37 px, given an alignment 2 px off: refined to a tenth of + /// a pixel at the corners. + #[test] + fn refines_a_rough_alignment_to_a_fraction_of_a_pixel() { + let (w, h) = (900usize, 600usize); + let reference = plane(w, h, &Homography::IDENTITY); + // the frame's pixel q shows the scene at s(q); reference → frame is s⁻¹ + let (c, sn) = (0.2f64.to_radians().cos(), 0.2f64.to_radians().sin()); + let s = Homography([c, -sn, 1.37, sn, c, -0.83, 0.0, 0.0, 1.0]); + let truth = s.inverse().unwrap(); + let frame = plane(w, h, &s); + let rough = Homography([truth.0[0], truth.0[1], truth.0[2] + 1.6, truth.0[3], truth.0[4], truth.0[5] - 1.2, 0.0, 0.0, 1.0]); + assert!(corner_error(&rough, &truth, w as f64, h as f64) > 1.5); + let r = fit(&reference, &frame, &rough, 4, 1, &ALL_MODELS).unwrap(); + let e = corner_error(&r.h, &truth, w as f64, h as f64); + assert!(e < 0.1, "corner error {e} px, model {}", r.model); + assert!(r.inliers >= MIN_MATCHES); + } + + /// A frame the small versions couldn't align, 5 px and 0.1° off: found from nothing. + #[test] + fn recovers_a_frame_with_no_alignment() { + let (w, h) = (900usize, 600usize); + let reference = plane(w, h, &Homography::IDENTITY); + let (c, sn) = (0.1f64.to_radians().cos(), 0.1f64.to_radians().sin()); + let s = Homography([c, -sn, -4.6, sn, c, 2.1, 0.0, 0.0, 1.0]); + let r = fit(&reference, &plane(w, h, &s), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).unwrap(); + let e = corner_error(&r.h, &s.inverse().unwrap(), w as f64, h as f64); + assert!(e < 0.1, "corner error {e} px, model {}", r.model); + assert!(fit(&reference, &Plane::from_fn(w, h, |_, _| 0.5), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).is_none()); + } + + /// Nothing to match (a flat frame, planes too small for a patch, empty ones): no fit. + #[test] + fn no_fit_when_nothing_matches() { + let h = Homography([1.0, 0.0, 0.7, 0.0, 1.0, 0.2, 0.0, 0.0, 1.0]); + let flat = Plane::from_fn(400, 300, |_, _| 0.5); + assert!(fit(&flat, &flat, &h, 4, 1, &ALL_MODELS).is_none()); + let tiny = Plane::from_fn(20, 20, |x, _| x as f32); + assert!(fit(&tiny, &tiny, &h, 4, 1, &ALL_MODELS).is_none()); + assert!(fit(&Plane::new(0, 0), &Plane::new(0, 0), &h, 4, 1, &ALL_MODELS).is_none()); + } + + #[test] + fn clipped_pixels_and_odd_gains_are_left_out() { + let img = Rgb32f::from_fn(4, 1, |x, _| [x as f32 * 0.33; 3]); + let l = luma(&img, 1.0, 1.0); + assert!(l.data[0] == 0.0 && l.data[3].is_nan(), "{:?}", l.data); + let l = luma(&img, 4.0, 1.0); + assert!(l.data[1].is_nan(), "0.33 × 4 is past 1: {:?}", l.data); + assert!(luma(&img, f32::NAN, 1.0).data[1].is_finite()); + assert_eq!(radius_for(0.125), 16); + assert_eq!(radius_for(1.0), 3); + assert_eq!(radius_for(f64::NAN), 3); + } +} diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index cb45274d1..f24bb5f2f 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -456,6 +456,14 @@ pub(crate) mod tests { } } +/// An exact `1/N` rational for a sub-second value whose reciprocal is a whole number (shutter speeds such as +/// 1/12800 s, which [`lightcraft_tiff::writer::rational`]'s decimal denominators cannot represent). +fn reciprocal(v: Option<f64>) -> Option<lightcraft_tiff::Value> { + let v = v.filter(|v| v.is_finite() && *v > 0.0 && *v < 1.0)?; + let inv = 1.0 / v; + ((inv - inv.round()).abs() < 1e-6 && inv.round() <= u32::MAX as f64).then(|| lightcraft_tiff::Value::Rational(vec![(1, inv.round() as u32)])) +} + /// Serialise the interchange subset of `meta` as a TIFF-structured Exif block (little-endian, no /// `Exif\0\0` prefix): IFD0 (make, model, software, artist, copyright, description, orientation, /// date), the Exif IFD (capture settings, lens) and, when present, the GPS IFD. @@ -483,7 +491,7 @@ pub fn write_exif(meta: &Metadata) -> Vec<u8> { let r = |v: Option<f64>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)])); for (tag, v) in [ (t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))), - (t::EXPOSURE_TIME, r(meta.exposure_time)), + (t::EXPOSURE_TIME, reciprocal(meta.exposure_time).or_else(|| r(meta.exposure_time))), (t::F_NUMBER, r(meta.f_number)), (t::FOCAL_LENGTH, r(meta.focal_length)), (t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))), @@ -561,4 +569,13 @@ mod write_tests { assert_eq!(back.iso, Some(iso)); } } + + #[test] + fn fast_shutter_speeds_round_trip_exactly() { + for den in [8000.0, 12_800.0, 16_000.0, 32_000.0] { + let back = read_exif(&write_exif(&Metadata { exposure_time: Some(1.0 / den), ..Default::default() })); + let got = back.exposure_time.unwrap(); + assert!((1.0 / got - den).abs() < 0.5, "1/{den} s came back as 1/{}", 1.0 / got); + } + } } diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index 197020e45..b62588b6d 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -19,6 +19,7 @@ rayon = { workspace = true } serde = { workspace = true } [dev-dependencies] +dhat = { workspace = true } lightcraft-scenes = { workspace = true } proptest = { workspace = true } image = { workspace = true } diff --git a/crates/pipeline/examples/guided_memory.rs b/crates/pipeline/examples/guided_memory.rs new file mode 100644 index 000000000..9f6beaa2a --- /dev/null +++ b/crates/pipeline/examples/guided_memory.rs @@ -0,0 +1,84 @@ +//! Isolated fast-guided-filter benchmark (procedural log luminance; no decoded RGB or tone median). +//! `RAYON_NUM_THREADS=1 cargo run --release -p lightcraft-pipeline --example guided_memory -- new` +//! Replace `new` with `old` for the materialized-coefficient reference. Optional positional arguments: +//! width height sigma iterations (defaults: 6000 4000 120 5). Append `--heap` for one dhat-counted run. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use lightcraft_pipeline::local::{BASE_EPS, guided, guided_fast, guided_fast_step}; +use lightcraft_raster::blur::gaussian; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Plane, par_join, par_rows}; +use std::hint::black_box; +use std::time::Instant; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +/// Previous algorithm; kept here only to make before/after measurements reproducible. +fn materialized(p: &Plane, sigma: f32) -> Plane { + let s = guided_fast_step(sigma); + if s <= 1 { + return guided(p, sigma, BASE_EPS); + } + let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box); + let (a, b) = coefficients(&lo, sigma / s as f32); + let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear)); + let mut q = Plane::new(p.width, p.height); + par_rows(&mut q.data, p.width, |y, row| { + for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) { + *v = av * pv + bv; + } + }); + q +} + +fn coefficients(lo: &Plane, sg: f32) -> (Plane, Plane) { + let (mean, corr) = par_join(|| gaussian(lo, sg), || gaussian(&lo.map(|v| v * v), sg)); + let a = corr.zip_map(&mean, |c, m| { + let v = (c - m * m).max(0.0); + v / (v + BASE_EPS) + }); + let b = mean.zip_map(&a, |m, a| m - a * m); + par_join(|| gaussian(&a, sg), || gaussian(&b, sg)) +} + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let mode = args.first().map(String::as_str).unwrap_or("new"); + if !matches!(mode, "old" | "new") { + return Err("mode must be old or new".into()); + } + let parse = |i: usize, default: usize| -> Result<usize, Box<dyn std::error::Error>> { + Ok(args.get(i).filter(|v| !v.starts_with("--")).map(|v| v.parse()).transpose()?.unwrap_or(default)) + }; + let (w, h, sigma, n) = (parse(1, 6000)?, parse(2, 4000)?, parse(3, 120)? as f32, parse(4, 5)?.max(1)); + if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > 100_000_000) { + return Err("dimensions must be positive and at most 100 MP".into()); + } + let heap = args.iter().any(|a| a == "--heap"); + let p = Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 97.0 - 4.0 + if x > w / 2 { 2.0 } else { 0.0 }); + let filter = || if mode == "old" { materialized(&p, sigma) } else { guided_fast(&p, sigma, BASE_EPS) }; + // Initialize Rayon and warm code paths before counting. The source is excluded from heap stats. + black_box(filter()); + let profiler = heap.then(|| dhat::Profiler::builder().testing().build()); + let mut best = f64::MAX; + for _ in 0..if heap { 1 } else { n } { + let t = Instant::now(); + let q = black_box(filter()); + best = best.min(t.elapsed().as_secs_f64() * 1000.0); + if heap { + let stats = dhat::HeapStats::get(); + println!( + "peak_bytes={} total_bytes={} total_blocks={} retained_output_bytes={}", + stats.max_bytes, + stats.total_bytes, + stats.total_blocks, + q.data.len() * size_of::<f32>() + ); + } + } + drop(profiler); + println!("{mode} {w}x{h} sigma={sigma} step={} heap={heap} min_wall_ms={best:.3}", guided_fast_step(sigma)); + Ok(()) +} diff --git a/crates/pipeline/src/curve.rs b/crates/pipeline/src/curve.rs new file mode 100644 index 000000000..b2c2b0b14 --- /dev/null +++ b/crates/pipeline/src/curve.rs @@ -0,0 +1,238 @@ +//! The parametric tone curve model shared by the pipeline renderer and the UI curve editor. +//! +//! Lightroom-style tone curve parametric model: four regions (shadows, darks, lights, highlights) +//! separated by three split points. Each region contributes a cosine-windowed bump, summed and +//! forced monotone. + +use lightcraft_color::spline::Lut1; +use lightcraft_develop::ToneCurve; + +/// The four shape controls of the parametric curve, in graph order: region 0 is the darkest. +pub const PARAM_AMOUNTS: [&str; 4] = ["curve.shadows", "curve.darks", "curve.lights", "curve.highlights"]; + +/// Resolution the parametric curve is sampled at, matching the renderer's LUT. +pub const PARAM_LUT: usize = 1024; + +/// The parametric (region) curve of a [`ToneCurve`]: the four region amounts, each a cosine bump +/// over its window between two splits, summed and then forced monotone. +/// +/// This is the renderer's curve, not an approximation of it — both `curve_luts` in the pipeline +/// and the curve editor in the UI use this struct so they can never disagree. +#[derive(Clone, Debug)] +pub struct ParametricCurve { + /// The curve, sampled and already forced monotone. + lut: [f32; PARAM_LUT], + /// The three region boundaries as fractions of the graph (0..1). + pub splits: [f64; 3], + /// The four region amounts, in graph order (0 = shadows, 3 = highlights). + pub amounts: [f64; 4], +} + +impl ParametricCurve { + pub fn new(c: &ToneCurve) -> ParametricCurve { + let s0 = if c.split_shadows.is_nan() { 0.25 } else { (c.split_shadows / 100.0).clamp(0.0, 1.0) as f32 }; + let s1 = if c.split_mid.is_nan() { 0.50_f32.max(s0) } else { (c.split_mid / 100.0).clamp(s0 as f64, 1.0) as f32 }; + let s2 = if c.split_highlights.is_nan() { 0.75_f32.max(s1) } else { (c.split_highlights / 100.0).clamp(s1 as f64, 1.0) as f32 }; + let clean_amt = |a: f64| if a.is_nan() { 0.0 } else { a.clamp(-100.0, 100.0) }; + let (a0, a1, a2, a3) = (clean_amt(c.shadows), clean_amt(c.darks), clean_amt(c.lights), clean_amt(c.highlights)); + let splits = [s0 as f64, s1 as f64, s2 as f64]; + let amounts = [a0, a1, a2, a3]; + let regions = [(0.0, s0, a0), (s0, s1, a1), (s1, s2, a2), (s2, 1.0, a3)]; + let mut lut = [0.0_f32; PARAM_LUT]; + let mut prev = 0.0_f32; + for (i, entry) in lut.iter_mut().enumerate() { + let x = i as f32 / (PARAM_LUT - 1) as f32; + let mut d = 0.0_f32; + for (a, b, amt) in regions { + if amt == 0.0 { + continue; + } + let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); + if half <= f32::EPSILON { + continue; + } + let t = ((x - ctr) / half).clamp(-1.0, 1.0); + d += (amt / 100.0) as f32 * 0.22 * (0.5 + 0.5 * (t * std::f32::consts::PI).cos()); + } + let y = (x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0); + prev = prev.max(y); + *entry = prev; + } + ParametricCurve { lut, splits, amounts } + } + + /// Convert the evaluated parametric curve into a 1024-entry [`Lut1`]. + pub fn to_lut(&self) -> Lut1 { + Lut1 { v: self.lut.to_vec() } + } + + /// The curve at `x` (0 = black, 1 = white), read off the table (so it never overshoots the ends). + pub fn at(&self, x: f64) -> f64 { + let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) as f32 }; + let at = x * (PARAM_LUT - 1) as f32; + let i = at.floor().clamp(0.0, (PARAM_LUT - 1) as f32) as usize; + let cur = self.lut.get(i).copied().unwrap_or(0.0); + let next = self.lut.get(i + 1).copied().unwrap_or(cur); + let f = at - i as f32; + (cur + (next - cur) * f) as f64 + } + + /// Where region `region`'s handle sits on the input axis: the middle of its own window, so a + /// handle never has to cross a split to be grabbed. + pub fn region_x(&self, region: usize) -> f64 { + let (a, b) = self.region_window(region); + (a + b) / 2.0 + } + + /// The window (two splits, as fractions of the graph) region `region` owns. + pub fn region_window(&self, region: usize) -> (f64, f64) { + let s0 = self.split_x(0); + let s1 = self.split_x(1); + let s2 = self.split_x(2); + match region { + 0 => (0.0, s0), + 1 => (s0, s1), + 2 => (s1, s2), + _ => (s2, 1.0), + } + } + + /// The half-width of region `region`'s own bump, as fraction of the graph. + pub fn region_half(&self, region: usize) -> f64 { + let (a, b) = self.region_window(region); + (b - a) * 0.75 + 0.05 + } + + /// The cosine window region `region` applies at `x` (1 at its centre, 0 at the window edges). + pub fn region_window_at(&self, region: usize, x: f64) -> f64 { + let ctr = self.region_x(region); + let half = self.region_half(region); + if half <= f64::EPSILON { + return 0.0; + } + let t = ((x - ctr) / half).clamp(-1.0, 1.0); + 0.5 + 0.5 * (t * std::f64::consts::PI).cos() + } + + /// The region that owns graph position `x`: the one whose own window contains it. The four + /// windows tile 0..1, so this is always defined and a drag anywhere on the curve moves exactly + /// one slider — the region under the pointer. + pub fn region_at(&self, x: f64) -> usize { + let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; + let s0 = self.split_x(0); + let s1 = self.split_x(1); + let s2 = self.split_x(2); + if x <= s0 { + 0 + } else if x <= s1 { + 1 + } else if x <= s2 { + 2 + } else { + 3 + } + } + + /// How much output level region `region`'s bump gains at `x` per unit of its slider: the bump is + /// linear in the amount, so this is a constant for a given `x`. A drag converts the pointer's + /// vertical motion into a slider change through it. + pub fn level_per_unit(&self, region: usize, x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + 0.22 / 100.0 * self.region_window_at(region, x) * 4.0 * x * (1.0 - x) + } + + /// The baseline level at `x` from the identity diagonal plus the contributions of all regions + /// *except* `region`. Used when inverting the model for `region` while holding the other three + /// regions fixed. + pub fn base_level_without(&self, region: usize, x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + let mut d = 0.0; + for i in 0..4 { + if i == region { + continue; + } + let amt = self.amounts.get(i).copied().unwrap_or(0.0); + if amt == 0.0 { + continue; + } + d += (amt / 100.0) * 0.22 * self.region_window_at(i, x); + } + x + d * 4.0 * x * (1.0 - x) + } + + /// The curve level region `region` sits at when its slider holds `amount`, at graph position `x`, + /// holding the other regions fixed at their current settings. + pub fn handle_level_at(&self, region: usize, amount: f64, x: f64) -> f64 { + (self.base_level_without(region, x) + amount * self.level_per_unit(region, x)).clamp(0.0, 1.0) + } + + /// The amount that puts region `region`'s curve at `level` at `x`, with the other three + /// regions held fixed — the inverse of [`Self::handle_level_at`]. A level the region cannot + /// reach saturates rather than jumping. + pub fn amount_at_level(&self, region: usize, level: f64, x: f64) -> f64 { + let per_unit = self.level_per_unit(region, x); + if per_unit.abs() <= f64::EPSILON { + return self.amounts.get(region).copied().unwrap_or(0.0); + } + let base = self.base_level_without(region, x); + ((level.clamp(0.0, 1.0) - base) / per_unit).clamp(-100.0, 100.0).round() + } + + /// The graph position split `split` (0..=2) sits at, 0..1. + pub fn split_x(&self, split: usize) -> f64 { + self.splits.get(split).copied().unwrap_or(0.5) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_finite_or_inverted_splits_do_not_panic_or_poison_curve() { + let c = ToneCurve { + split_shadows: 80.0, + split_mid: 20.0, + split_highlights: f64::NAN, + shadows: f64::NAN, + lights: f64::INFINITY, + ..Default::default() + }; + let model = ParametricCurve::new(&c); + assert!(model.at(0.5).is_finite()); + assert!(model.at(f64::NAN).is_finite()); + assert!(model.region_at(f64::NAN) < 4); + assert!((model.splits[0] - 0.8).abs() < 1e-6); + assert!((model.splits[1] - 0.8).abs() < 1e-6); + assert!((model.splits[2] - 0.8).abs() < 1e-6); + } + + #[test] + fn horizontal_inversion_at_x_0_51_with_darks_60_preserves_zero_lights() { + let c = ToneCurve { darks: 60.0, lights: 0.0, ..Default::default() }; + let model = ParametricCurve::new(&c); + assert_eq!(model.region_at(0.51), 2, "x=0.51 is inside the Lights region"); + let level = model.at(0.51); + assert!((level - 0.5619).abs() < 0.001, "extracted model at 0.51 is ~0.5619: {level}"); + // With other regions held fixed, inverting at level ~0.5619 must return Lights = 0, NOT 45! + let lights = model.amount_at_level(2, level, 0.51); + assert_eq!(lights, 0.0, "zero vertical delta preserves Lights=0: {lights}"); + } + + #[test] + fn a_curve_level_and_its_amount_are_inverses_with_other_regions_present() { + let c = ToneCurve { shadows: 20.0, darks: -30.0, lights: 40.0, highlights: -10.0, ..Default::default() }; + let model = ParametricCurve::new(&c); + for region in 0..4 { + let centre = model.region_x(region); + let (_, upper) = model.region_window(region); + for x in [centre, centre + (upper - centre) * 0.5] { + for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] { + let level = model.handle_level_at(region, amount, x); + let back = model.amount_at_level(region, level, x); + assert!((back - amount).abs() <= 1.0, "region {region} at {x}: {amount} -> {level} -> {back}"); + } + } + } + } +} diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index eb65d51a2..c1e6a9bc2 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -29,31 +29,20 @@ fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> { return None; } const N: usize = 1024; - let (s1, s2, s3) = ((c.split_shadows / 100.0) as f32, (c.split_mid / 100.0) as f32, (c.split_highlights / 100.0) as f32); - let regions = [(0.0, s1, c.shadows), (s1, s2, c.darks), (s2, s3, c.lights), (s3, 1.0, c.highlights)]; - let mut base = Lut1::from_fn(N, |x| { - let mut d = 0.0; - for (a, b, amt) in regions { - if amt == 0.0 { - continue; - } - let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); - let t = ((x - ctr) / half).clamp(-1.0, 1.0); - let win = 0.5 + 0.5 * (t * std::f32::consts::PI).cos(); - d += (amt / 100.0) as f32 * 0.22 * win; - } - (x + d * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0) - }); - // keep monotone - for i in 1..N { - base.v[i] = base.v[i].max(base.v[i - 1]); - } + let mut base = crate::curve::ParametricCurve::new(c).to_lut(); let to_pts = |p: &[Point]| p.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>(); if master { base = MonotoneCurve::new(&to_pts(&c.master)).to_lut(N).compose(&base); } let per = [&c.red, &c.green, &c.blue]; - Some(std::array::from_fn(|i| if chans[i] { MonotoneCurve::new(&to_pts(per[i])).to_lut(N).compose(&base) } else { base.clone() })) + Some(std::array::from_fn(|i| { + if chans.get(i).copied().unwrap_or(false) { + let pts = per.get(i).map(|&p| to_pts(p)).unwrap_or_default(); + MonotoneCurve::new(&pts).to_lut(N).compose(&base) + } else { + base.clone() + } + })) } /// Vignette (post-crop) parameters. @@ -450,7 +439,12 @@ pub(crate) fn finish_with<T: Copy + Default + Send>( if dz != 0.0 && let Some(dark) = &p.dark { - let d = (dark.data[i] / air_pre).clamp(0.0, 1.0); + // The dark channel is a blur, not a patch minimum: near bright neighbours it credits + // dark pixels with veil they don't hold (and a windowed render borrows the whole + // frame's airlight), so the subtraction went past zero and crushed them to black + // (#2504). Never attribute more veil than the pixel's own minimum channel. + let c_min = c[0].min(c[1]).min(c[2]); + let d = (if air_pre > 0.0 { dark.data[i] / air_pre } else { 0.0 }).min(if air > 0.0 { c_min / air } else { 0.0 }).clamp(0.0, 1.0); if dz > 0.0 { let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12); c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0)); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 34df1f8a9..8f2702760 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -27,6 +27,7 @@ pub mod auto; pub mod blemish; pub mod colorops; pub mod cull; +pub mod curve; pub mod dust; pub mod finish; pub mod geometry; @@ -44,6 +45,7 @@ pub mod transform; pub mod upright; pub mod visualize; +pub use curve::{PARAM_AMOUNTS, PARAM_LUT, ParametricCurve}; pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof, ProofLut, proof_profile, register_proof_profile}; pub use visualize::{MaskView, Overlay}; @@ -139,7 +141,7 @@ pub struct RenderRequest { pub space: OutputSpace, /// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports). pub depth: OutputDepth, - /// Soft proofing (CPU only; see [`Proof`]). + /// Soft proofing (a proof against an output-device profile renders on the CPU; see [`Proof`]). pub proof: Option<Proof>, /// Render only this window of the output that `max_w × max_h` describes (a zoomed view): /// the result is that window's pixels, as in the whole render. See [`PixelWindow`]. diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 705b4026e..c91872d38 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -178,10 +178,55 @@ fn guided_coeffs(p: &Plane, sigma: f32, eps: f32) -> (Plane, Plane) { par_join(|| gaussian(&a, sigma), || gaussian(&b, sigma)) } -/// `q = a·p + b` per pixel. +/// `q = a·p + b` per pixel. Reduced coefficients are sampled with the same separable +/// weights and accumulation order as a bilinear resize, without full-size coefficient planes. fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane { let mut q = Plane::new(p.width, p.height); let w = p.width; + if (a.width, a.height) != (p.width, p.height) { + use lightcraft_raster::resample::{Filter, weights}; + let wx = weights(a.width, w, Filter::Bilinear); + let wy = weights(a.height, p.height, Filter::Bilinear); + // Bilinear upsampling has at most four taps (including zero-weight boundary taps). + // Keep only those horizontally sampled rows per band, reusing them for neighbouring + // output rows. Scratch scales with width and active bands, not image area. + const BAND: usize = 32; + for_rows(&mut q.data, w.saturating_mul(BAND), |band, rows| { + let mut cache: [(usize, Vec<f32>, Vec<f32>); 4] = std::array::from_fn(|_| (usize::MAX, vec![0.0; w], vec![0.0; w])); + let mut bias = vec![0.0; w]; + for (dy, row) in rows.chunks_mut(w).enumerate() { + let y = band * BAND + dy; + let (top, ys) = &wy[y]; + bias.fill(0.0); + for (dy, yw) in ys.iter().enumerate() { + let cy = top + dy; + let (cached_y, aa, bb) = &mut cache[cy % 4]; + if *cached_y != cy { + let (ar, br) = (a.row(cy), b.row(cy)); + for ((av, bv), (left, xs)) in aa.iter_mut().zip(bb.iter_mut()).zip(&wx) { + *av = 0.0; + *bv = 0.0; + for (dx, xw) in xs.iter().enumerate() { + *av += ar[left + dx] * xw; + *bv += br[left + dx] * xw; + } + } + *cached_y = cy; + } + for (av, ax) in row.iter_mut().zip(aa) { + *av += *ax * yw; + } + for (bv, bx) in bias.iter_mut().zip(bb) { + *bv += *bx * yw; + } + } + for ((v, pv), bv) in row.iter_mut().zip(p.row(y)).zip(&bias) { + *v = *v * pv + bv; + } + } + }); + return q; + } for_rows(&mut q.data, w, |y, row| { for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) { *v = av * pv + bv; @@ -191,7 +236,7 @@ fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane { } /// Fast guided filter (He & Sun 2015): coefficients computed on a `s×` subsampled plane and -/// bilinearly upsampled; the output keeps full-resolution edges. Equivalent to [`guided`] for +/// sampled bilinearly at full resolution; the output keeps full-resolution edges. Equivalent to [`guided`] for /// large windows at a fraction of the cost. pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { use lightcraft_raster::resample::{Filter, resize}; @@ -202,8 +247,7 @@ pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { let (lw, lh) = (p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1)); let lo = resize(p, lw, lh, Filter::Box); let (ma, mb) = guided_coeffs(&lo, sigma / s as f32, eps); - let up = |c: &Plane| resize(c, p.width, p.height, Filter::Bilinear); - guided_apply(p, &up(&ma), &up(&mb)) + guided_apply(p, &ma, &mb) } /// Separable median (`2r+1` px along each row, then each column; clamped edges). It keeps the @@ -758,6 +802,60 @@ mod tests { mod fast_tests { use super::*; + /// The previous fast path, including its two materialized coefficient resizes. + fn resized_reference(p: &Plane, sigma: f32, eps: f32) -> Plane { + use lightcraft_raster::resample::{Filter, resize}; + let s = guided_fast_step(sigma); + if s <= 1 { + return guided(p, sigma, eps); + } + let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box); + let (a, b) = guided_coeffs(&lo, sigma / s as f32, eps); + let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear)); + Plane::from_fn(p.width, p.height, |x, y| a.get(x, y) * p.get(x, y) + b.get(x, y)) + } + + #[test] + fn sampled_coefficients_match_materialized_resizes() { + for (w, h) in [(0, 0), (1, 1), (1, 19), (23, 1), (2, 3), (17, 13), (64, 48), (161, 119)] { + let planes = [ + Plane::filled(w, h, -3.25), + Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 7.0 - 8.0), + Plane::from_fn(w, h, |x, y| { + if x == w / 2 && y == h / 2 { + 9.0 + } else if x < w / 2 { + -5.0 + } else { + 2.0 + } + }), + ]; + // Step 1 fallback, every small step, Draft's cap, and the maximum step (16). + for sigma in [0.0, 2.99, 5.99, 6.0, 9.0, 12.0, 24.0, 48.0, 200.0] { + for eps in [BASE_EPS, CLARITY_EPS, 0.0001] { + for p in &planes { + let expected = resized_reference(p, sigma, eps); + let actual = guided_fast(p, sigma, eps); + assert_eq!((actual.width, actual.height), (w, h)); + assert_eq!(actual.data, expected.data, "{w}x{h}, sigma {sigma}, eps {eps}"); + } + } + } + } + } + + #[test] + fn sampled_tone_base_keeps_the_median_before_the_guided_filter() { + let p = Plane::from_fn(73, 51, |x, y| if x % 9 == 0 { 3.0 } else { -4.0 + ((x + y) % 7) as f32 * 0.03 }); + for sigma in [6.0, 24.0, 120.0] { + for detail in [0, 1, 3, BASE_DETAIL_MAX] { + let median = median_hv(&p, detail); + assert_eq!(tone_base(&p, sigma, detail).data, resized_reference(&median, sigma, BASE_EPS).data, "{sigma}, {detail}"); + } + } + } + #[test] fn fast_guided_close_to_reference() { let p = Plane::from_fn(160, 120, |x, y| ((x as f32 * 0.13).sin() + (y as f32 * 0.07).cos()) * 2.0 + if x > 80 { 1.5 } else { 0.0 }); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index d7949315e..23eca0ba7 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -75,6 +75,38 @@ fn directional_sliders() { assert!(at("effects.dehaze", -100.0) != base); } +/// Dehaze crushed dark regions next to bright ones to black: the dark channel is a blur, not a +/// patch minimum, so near the boundary it credited the dark pixels with the bright side's veil and +/// the subtraction went past zero (#2504). The veil credited to a pixel is now bounded by its own +/// minimum channel, so a uniform dark region renders uniformly under dehaze. +#[test] +fn dehaze_does_not_crush_dark_regions_next_to_bright_ones() { + // a dark field against a bright hazy sky; the blur bleeds the sky's dark channel ~10 px into + // the field, which is where the blotches formed + let src = Rgb32f::from_fn(240, 160, |x, _| if x < 120 { [0.02, 0.03, 0.012] } else { [0.45, 0.5, 0.55] }); + let mut s = DevelopSettings::default(); + s.effects.dehaze = 80.0; + let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(240, 160)).image; + for ch in 0..3 { + let mut v: Vec<u8> = Vec::with_capacity(160 * 100); + for y in 0..160 { + for x in 0..100 { + v.push(r.get(x, y)[ch]); + } + } + v.sort_unstable(); + let mid = v[v.len() / 2] as i32; + // a field rendered all black would pass the comparison below vacuously + assert!(mid > 2, "the dark field itself is crushed in ch {ch}: level {mid}"); + for y in 0..160 { + for x in 0..120 { + let p = r.get(x, y)[ch] as i32; + assert!(p >= mid - 2, "crushed at {x},{y} ch {ch}: {p} vs field level {mid}"); + } + } + } +} + #[test] fn resolution_independence_of_local_contrast() { // Clarity at two preview sizes should give similar results after downscaling. diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index b0fc3de8f..b82a19f45 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -407,3 +407,42 @@ fn auto_mask_strokes_match_the_whole_render() { }; check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1); } + +/// The margin the loupe's windows carry around what they show at this size (`region::margin_for`: +/// 4.5 % of the long edge). +const OVERLAY_INSET: usize = W * 45 / 1000; + +/// [`check`] for a diagnostic overlay, with the photo as a raw (Visualize HDR needs one), keeping the +/// loupe's margin in from the window's edges. +fn check_overlay(name: &str, s: &DevelopSettings, overlay: crate::Overlay, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let info = SourceInfo { raw: true, ..Default::default() }; + let full_req = RenderRequest { overlay, ..RenderRequest::fit(W, H) }; + let f = render(&scene(), &info, s, &full_req).image; + let w = render(&scene(), &info, s, &RenderRequest { window: Some(win), ..full_req }).image; + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + assert_ne!(f.data, render(&scene(), &info, s, &RenderRequest::fit(W, H)).image.data, "{name}: the overlay draws something"); + let (max, mean) = compare_inset(&f, &w, win, OVERLAY_INSET); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +// Given a diagnostic overlay (Visualize Spots, a Point Color range, Visualize HDR), a window shows +// the same pixels as the whole-frame render, so the loupe can draw them as a zoom window +#[test] +fn diagnostic_overlays_in_a_window_match_the_full_render() { + use crate::Overlay; + let mut spots = DevelopSettings::default(); + spots.detail.sharpen_amount = 60.0; + // a binary threshold: a pixel at the threshold may flip on a one-level difference underneath + check_overlay("spots", &spots, Overlay::Spots(70), MID, 255, 0.05); + for (x, y) in [(0, 0), (W - 400, H - 300)] { + check_overlay("spots at a corner", &spots, Overlay::Spots(70), PixelWindow { x, y, w: 400, h: 300 }, 255, 0.05); + } + let mut pc = DevelopSettings::default(); + pc.point_colors.push(lightcraft_develop::PointColor { lum: 0.6, chroma: 0.08, hue: 60.0, hue_shift: 40.0, ..Default::default() }); + check_overlay("point colour range", &pc, Overlay::PointColorRange(0), MID, 1, 0.1); + let mut hdr = DevelopSettings::default(); + hdr.hdr.enabled = true; + hdr.hdr.max_ev = 3.0; + hdr.light.exposure = 1.5; + check_overlay("hdr range", &hdr, Overlay::HdrRange, MID, 1, 0.1); +} diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 62c9a2505..10bb24d50 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -8,8 +8,9 @@ //! (HE / HE★, Z 8, Z 9, Z 6III, Z f): they keep compression 34713 but carry a wavelet codestream that starts with //! the JPEG XS markers SOC + CAP (ISO/IEC 21122-1, `FF10 FF50`) and have no `0x0096` table (issue #193). //! Strips labelled 34713 without a `0x0096` -//! linearization table are packed uncompressed data in several measured layouts ([`PackedLayout`]); Nikon small raw -//! (sRAW) and the D1X's genuinely Huffman-coded strip stay refused. +//! linearization table are packed uncompressed data in several measured layouts ([`PackedLayout`]) or, when the strip +//! is exactly three bytes per pixel, Nikon small raw ([`small_raw`]); the D1X's genuinely Huffman-coded strip stays +//! refused. use super::{nefc, white_from_data}; use crate::tiffraw::{Packing, check_image, read_image}; @@ -233,6 +234,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { let make = ifd0.string(t::MAKE).unwrap_or_default(); let mn = tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); + if let Some(strip) = small_raw_strip(&info, bytes.len(), mn.as_ref()) { + return small_raw(bytes, &tiff, &info, strip, mode); + } // the header probe reads only the rows the masked-column test needs let rows = match mode { Mode::Full => h, @@ -494,14 +498,7 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result<RawData> { _ => None, }; let (Some(layout), [c]) = (layout, chunks.as_slice()) else { - let small = bits == 12 - && chunks.len() == 1 - && w.checked_mul(h).and_then(|n| n.checked_mul(3)).is_some_and(|n| u64::try_from(n).ok() == Some(chunks[0].len)); - return Err(RawError::Unsupported(if small { - "Nikon small raw (sRAW) not decoded yet".into() - } else { - "Nikon compressed NEF without a linearization table (maker note 0x96)".into() - })); + return Err(RawError::Unsupported("Nikon compressed NEF without a linearization table (maker note 0x96)".into())); }; let src = lightcraft_tiff::image::chunk_bytes(bytes, c) .filter(|src| u64::try_from(src.len()).ok() == Some(c.len)) @@ -512,6 +509,128 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result<RawData> { Ok(RawData::U16(unpack_packed(src, w, rows.min(h), bits, layout))) } +/// Nikon small raw (sRAW), measured clean-room from the CC0 raw.pixls.us D810 (2036) and D4S (2227) samples. The raw SubIFD is labelled compression 34713, +/// 12 bits, one CFA sample per pixel, but it carries no linearization table (maker note `0x0096`) and its single strip +/// is exactly `3 * width * height` bytes. It holds a YCbCr 4:2:2 image of the IFD size, not a CFA mosaic (the CFA +/// tags are dummies): +/// +/// - the strip is an LSB-first 12-bit stream (`s0 = b0 | (b1 & 0x0f) << 8`, `s1 = b1 >> 4 | b2 << 4`), a row is `3 * width` +/// bytes with no padding, i.e. `2 * width` samples; +/// - the samples come in groups of four, `[Y_left, Y_right, Cb, Cr]`, for the pixel pair `(2k, 2k + 1)`: chroma is +/// halved horizontally and not vertically; +/// - luma is linear and already black-subtracted (the darkest samples are near 0) and never uses the top two bits +/// (maxima 873 and 1004 of 4095), so the white level is 1023; chroma is centred on 2048 (fitted neutral 2045-2049). +/// +/// The chroma is a camera colour-difference scale, not JFIF Cb/Cr. In linear terms the stored values behave as +/// `R - Y = R_GAIN * (Cr - 2048)` and `B - Y = B_GAIN * (Cb - 2048)` on the luma scale: regressing the camera JPEG's +/// linear `(R - Y) / Y` and `(B - Y) / Y` on `(Cr - 2048) / Y` and `(Cb - 2048) / Y` over mid-tone pixels gave slopes +/// 4.92 / 5.09 (D810, correlation 0.94 / 0.88) and 3.52 / 3.73 (D4S, 0.98 / 0.97); the gains below are the rounded +/// means. They are approximate (the two bodies differ by about 30 %), the luma is not tone-mapped, and the data is +/// treated as already white balanced (neutral D4S areas have `Cb`, `Cr` within 2 of 2048 and equal JPEG R, G, B). G +/// follows from the luma equation with the BT.601 weights. The chroma is upsampled horizontally with linear +/// interpolation, centred between the two pixels of a pair. Output is linear RGB scaled by 16 (to a 14-bit range), +/// like Sony's downsized ARWs. +const SMALL_RAW_WHITE: f32 = 1023.0; +const SMALL_RAW_SCALE: f32 = 16.0; +const SMALL_RAW_CHROMA_ZERO: f32 = 2048.0; +const SMALL_RAW_R_GAIN: f32 = 4.2; +const SMALL_RAW_B_GAIN: f32 = 4.4; + +/// The strip of a small raw: compression 34713, 12 bits, no maker note `0x0096`, one strip of exactly +/// `3 * width * height` bytes (even width). +fn small_raw_strip(info: &ImageInfo, file_len: usize, mn: Option<&makernote::MakerNote>) -> Option<lightcraft_tiff::image::Chunk> { + if info.compression != t::compression::NIKON || info.bits() != 12 || info.samples_per_pixel != 1 { + return None; + } + if mn.is_some_and(|m| m.ifd.bytes(LINEARIZATION_TABLE).is_some()) { + return None; + } + let (w, h) = (info.width as u64, info.height as u64); + let chunks = info.chunks(file_len as u64); + match chunks.as_slice() { + [c] if w.is_multiple_of(2) && w > 0 && h > 0 && w.checked_mul(h).and_then(|n| n.checked_mul(3)) == Some(c.len) => Some(*c), + _ => None, + } +} + +/// Decode one row of a small raw (`3 * width` bytes) into `3 * width` linear RGB values on the 14-bit scale. +/// Short input reads as zero bytes. +fn small_raw_row(src: &[u8], w: usize, out: &mut [u16]) { + let Some(n) = w.checked_mul(3).filter(|n| *n <= out.len() && *n <= crate::MAX_SAMPLES) else { return }; + if w == 0 || !w.is_multiple_of(2) { + return; + } + let mut s = vec![0u16; 2 * w]; + crate::unpack::unpack_lsb(&src[..src.len().min(n)], 12, &mut s); + let pairs = w / 2; + let chroma = |k: usize| { + let g = &s[4 * k + 2..4 * k + 4]; + (f32::from(g[0]) - SMALL_RAW_CHROMA_ZERO, f32::from(g[1]) - SMALL_RAW_CHROMA_ZERO) + }; + for k in 0..pairs { + let (cb, cr) = chroma(k); + for side in 0..2 { + let (nb, nr) = if side == 0 { chroma(k.saturating_sub(1)) } else { chroma((k + 1).min(pairs - 1)) }; + let (u, v) = (0.75 * cb + 0.25 * nb, 0.75 * cr + 0.25 * nr); + let y = f32::from(s[4 * k + side]); + let r = y + SMALL_RAW_R_GAIN * v; + let b = y + SMALL_RAW_B_GAIN * u; + let g = (y - 0.299 * r - 0.114 * b) / 0.587; + let dst = (2 * k + side) * 3; + for (i, value) in [r, g, b].into_iter().enumerate() { + out[dst + i] = (value * SMALL_RAW_SCALE).round().clamp(0.0, 65535.0) as u16; + } + } + } +} + +fn small_raw(bytes: &[u8], tiff: &Tiff, info: &ImageInfo, strip: lightcraft_tiff::image::Chunk, mode: Mode) -> Result<RawImage> { + use rayon::prelude::*; + let (w, h) = (info.width as usize, info.height as usize); + let stride = w.checked_mul(3).ok_or(RawError::Limit("image too large"))?; + let count = stride.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let src = lightcraft_tiff::image::chunk_bytes(bytes, &strip) + .filter(|src| u64::try_from(src.len()).ok() == Some(strip.len)) + .ok_or_else(|| RawError::Corrupt("NEF: incomplete image strip".into()))?; + let data = match mode { + Mode::Full => { + let mut data = vec![0u16; count]; + data.par_chunks_mut(stride).enumerate().for_each(|(y, out)| { + small_raw_row(src.get(y * stride..).unwrap_or(&[]), w, out); + }); + data + } + Mode::Header => Vec::new(), + }; + let mut metadata = lightcraft_meta::from_tiff(tiff); + metadata.width = Some(w as u32); + metadata.height = Some(h as u32); + let img = RawImage { + format: RawFormat::Nef, + width: w, + height: h, + cpp: 3, + data: RawData::U16(data), + cfa: None, + bits: 14, + black: BlackLevel::uniform(0.0), + white: vec![SMALL_RAW_WHITE * SMALL_RAW_SCALE], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::from_exif(tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION)).unwrap_or(1)), + color: ColorData::default(), + // the stored colour is already white balanced (see above) + wb_multipliers: Some([1.0; 3]), + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata, + lens_metadata: Default::default(), + }; + img.validate_for(mode)?; + Ok(img) +} + #[cfg(test)] mod tests { use super::*; @@ -1089,9 +1208,119 @@ mod tests { // 14-bit rows must be padded to 16 bytes let d14 = samples(40 * 6, 14); assert!(matches!(decoded(strip(&d14, 40, 0, |r| pack_lsb(r, 14)), 14, 40, 6), Err(RawError::Unsupported(_)))); - // 3 bytes per IFD-width pixel is a Nikon small raw: still refused, with its own reason - let Err(RawError::Unsupported(why)) = decoded(vec![0; 3 * 40 * 6], 12, 40, 6) else { panic!("small raw decoded") }; - assert!(why.contains("sRAW"), "{why}"); + } + + /// A small raw strip of `[Y0 Y1 Cb Cr]` groups, rows of `3 * w` bytes, LSB-first 12-bit. + fn small_strip(groups: &[[u16; 4]], w: usize) -> Vec<u8> { + let flat: Vec<u16> = groups.iter().flatten().copied().collect(); + assert_eq!(flat.len() % (2 * w), 0); + flat.chunks(2 * w).flat_map(|r| pack_lsb(r, 12)).collect() + } + + fn small(strip: Vec<u8>, bits: u16, w: u32, h: u32) -> Result<RawImage> { + crate::decode(&nef(34713, bits, vec![strip], w, h, h)) + } + + #[test] + fn small_raw_422_round_trips() { + let (w, h) = (8usize, 3usize); + // flat chroma per row so the interpolation is exact; distinct luma everywhere + let mut groups = Vec::new(); + for y in 0..h { + for k in 0..w / 2 { + let (cb, cr) = (2048 + 10 * y as u16, 2048 - 5 * y as u16); + groups.push([100 + (y * w + 2 * k) as u16, 100 + (y * w + 2 * k + 1) as u16, cb, cr]); + } + } + let raw = small(small_strip(&groups, w), 12, w as u32, h as u32).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp, raw.cfa.is_none()), (w, h, 3, true)); + assert_eq!(raw.wb_multipliers, Some([1.0; 3])); + let RawData::U16(d) = &raw.data else { panic!() }; + for y in 0..h { + for x in 0..w { + let luma = (100 + y * w + x) as f32; + let (u, v) = (10.0 * y as f32, -5.0 * y as f32); + let (r, b) = (luma + SMALL_RAW_R_GAIN * v, luma + SMALL_RAW_B_GAIN * u); + let g = (luma - 0.299 * r - 0.114 * b) / 0.587; + for (i, want) in [r, g, b].into_iter().enumerate() { + let got = f32::from(d[(y * w + x) * 3 + i]) / SMALL_RAW_SCALE; + assert!((got - want).abs() < 0.1, "({x},{y}) channel {i}: {got} vs {want}"); + } + } + } + } + + #[test] + fn small_raw_neutral_chroma_is_grey_and_chroma_is_interpolated() { + let w = 8usize; + let groups: Vec<[u16; 4]> = (0..w / 2).map(|k| [200 + 40 * k as u16, 200 + 40 * k as u16, 2048, 2048]).collect(); + let raw = small(small_strip(&groups, w), 12, w as u32, 1).unwrap(); + let RawData::U16(d) = &raw.data else { panic!() }; + for x in 0..w { + let px = &d[x * 3..x * 3 + 3]; + assert!(px.iter().all(|&v| v == px[0]), "{px:?}"); + assert_eq!(f32::from(px[0]), (200 + 40 * (x / 2)) as f32 * SMALL_RAW_SCALE); + } + // a step in Cb between the first two pairs is shared 3:1 by the pixels next to the boundary + let mut groups = groups; + groups[1][2] = 2048 + 100; + let raw = small(small_strip(&groups, w), 12, w as u32, 1).unwrap(); + let RawData::U16(d) = &raw.data else { panic!() }; + let blue_minus_luma = |x: usize, luma: f32| f32::from(d[x * 3 + 2]) / SMALL_RAW_SCALE - luma; + assert!((blue_minus_luma(1, 200.0) - SMALL_RAW_B_GAIN * 25.0).abs() < 0.1); + assert!((blue_minus_luma(2, 240.0) - SMALL_RAW_B_GAIN * 75.0).abs() < 0.1); + } + + #[test] + fn small_raw_wrong_sizes_and_depths_are_refused() { + let (w, h) = (8usize, 3usize); + let good = small_strip(&vec![[100, 100, 2048, 2048]; w / 2 * h], w); + assert!(small(good.clone(), 12, w as u32, h as u32).is_ok()); + for len in [good.len() - 1, good.len() + 1] { + let mut s = good.clone(); + s.resize(len, 0); + assert!(matches!(small(s, 12, w as u32, h as u32), Err(RawError::Unsupported(_))), "len {len}"); + } + assert!(matches!(small(good.clone(), 14, w as u32, h as u32), Err(RawError::Unsupported(_)))); + // odd width: 3 bytes per pixel does not split into pixel pairs + assert!(matches!(small(vec![0; 3 * 7 * 3], 12, 7, 3), Err(RawError::Unsupported(_)))); + } + + #[test] + fn small_raw_short_input_does_not_panic() { + for len in [0, 1, 2, 3, 5, 11, 12, 13] { + let mut out = vec![0u16; 12]; + small_raw_row(&vec![0xFF; len], 4, &mut out); + } + let mut bytes = nef(34713, 12, vec![vec![0x55; 3 * 8 * 3]], 8, 3, 3); + bytes.truncate(bytes.len() - 40); + assert!(crate::decode(&bytes).is_err(), "a truncated small-raw strip is corrupt"); + } + + #[test] + fn small_raw_header_has_full_geometry_without_allocating_pixels() { + let (w, h) = (8usize, 3usize); + let bytes = nef(34713, 12, vec![small_strip(&vec![[100, 110, 2048, 2048]; w / 2 * h], w)], w as u32, h as u32, h as u32); + let header = decode(&bytes, Mode::Header).unwrap(); + let full = decode(&bytes, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert!(matches!(header.data, RawData::U16(ref samples) if samples.is_empty())); + assert!(matches!(full.data, RawData::U16(ref samples) if samples.len() == 3 * w * h)); + } + + #[test] + fn small_raw_rejects_excessive_dimensions_before_allocation() { + let bytes = nef(34713, 12, vec![vec![0; 72]], 8, 3, 3); + let tiff = Tiff::parse(&bytes).unwrap(); + let mut info = raw_ifd(&tiff).unwrap().image().unwrap(); + let strip = info.chunks(bytes.len() as u64)[0]; + info.width = u32::MAX - 1; + info.height = u32::MAX; + assert!(matches!(small_raw(&bytes, &tiff, &info, strip, Mode::Header), Err(RawError::Limit(_)))); + let mut out = [0; 12]; + small_raw_row(&[], usize::MAX, &mut out); + small_raw_row(&[], 3, &mut out); + assert_eq!(out, [0; 12]); } /// Big-endian 16-bit words holding the 12-bit samples in their upper bits. @@ -1186,4 +1415,15 @@ mod tests { } } } + + #[test] + fn small_raw_rejects_tail_truncation_with_intact_directories() { + let bytes = nef(34713, 12, vec![vec![0x55; 72]], 8, 3, 3); + for keep in [0, 24, 71] { + let short = truncated_tail_strip(bytes.clone(), keep); + for mode in [Mode::Header, Mode::Full] { + assert!(matches!(decode(&short, mode), Err(RawError::Corrupt(_)))); + } + } + } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d9b417a36..5ee1c0233 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -2,7 +2,7 @@ name = "lightcraft-ui-egui" version.workspace = true edition.workspace = true -rust-version = "1.95" +rust-version.workspace = true license.workspace = true description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)" diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 8a50d38f1..1eb882f09 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -253,6 +253,7 @@ "new, inside {parent}": "nové, pod {parent}", "Keywords of {n} selected photos": "Klíčová slova vybraných fotografií (počet: {n})", "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Klíčové slovo „{name}“ bylo odebráno z fotografií v počtu {n}. Úpravy → Zpět jej vrátí.", + "Inside: {names}": "Uvnitř: {names}", " in {g} group{}": " ve skupinách: {g}{:.0}", " · kept, not applied: {}": " · zachováno, nepoužito: {}", " — kept but not applied: {}": " — zachováno, ale nepoužito: {}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?", "Listing folders — folders: {folders}, files: {files}": "Procházení složek – složky: {folders}, soubory: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí.", + "Drop to review {n} item{} for import": "Pusťte pro kontrolu před importem (položek: {n}){:.0}", + "Drop to import {n} item{}": "Pusťte pro import (položek: {n}){:.0}", + "Drop to import {n} preset{}": "Pusťte pro import přednastavení: {n}{:.0}", + "Could not read {name}: {e}": "Soubor {name} se nepodařilo přečíst: {e}", + "Added {n} photo{}": "Přidáno fotografií: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Kam má jít katalog Lightroomu „{catalog}“?", + "Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})" } diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index e3a98569f..16095a43e 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -374,7 +374,6 @@ "Clear Filters": "Zrušit filtry", "Clear Guides": "Vymazat vodítka", "Clear History": "Vymazat historii", - "Clear Preview Cache": "Vymazat mezipaměť náhledů", "Clear Quick Collection": "Vyprázdnit rychlou kolekci", "Clear Recent Libraries": "Vymazat nedávné knihovny", "Clear Reference": "Zrušit referenční fotografii", @@ -425,7 +424,6 @@ "Company": "Společnost", "Compare": "Porovnat", "Compare (Shift+C)": "Porovnat (Shift+C)", - "Compare Before and After": "Porovnat před a po", "Compare needs at least two photos": "Porovnání vyžaduje alespoň dvě fotografie", "Component options": "Možnosti součásti", "Compression": "Komprese", @@ -1585,7 +1583,6 @@ "Reset all edits (Cmd+Shift+R)": "Obnovit všechny úpravy (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Inteligentní alba se aktualizují automaticky: změňte jejich pravidla nebo použijte Fotografie → Odstranit fotografii", "LightCraft stopped": "LightCraft se neočekávaně ukončil", - "Show Focus Point": "Zobrazit zaostřovací bod", "Zoom to Focus Point": "Přiblížit na zaostřovací bod", "Focus": "Zaostření", "Focus point": "Zaostřovací bod", @@ -1754,18 +1751,13 @@ "Short Edge": "Kratší hrana", "Show": "Zobrazit", "Show All": "Zobrazit vše", - "Show Clipping": "Zobrazit ořezání tónů", "Show Deghost Overlay": "Zobrazit překrytí odstranění duchů", "Show Hidden Local Locations": "Zobrazit skrytá místní umístění", "Show Luminance Map": "Zobrazit mapu jasu", "Show Mask": "Zobrazit masku", - "Show Mask Overlay": "Zobrazit překrytí masky", - "Show All Mask Overlays": "Zobrazit všechna překrytí masek", "Show all overlays": "Zobrazit všechna překrytí", - "Show Mask Pins": "Zobrazit body masek", "Show Original": "Zobrazit originál", "Show Person": "Zobrazit osobu", - "Show Photo Counts": "Zobrazit počty fotografií", "Show Photos with Keyword": "Zobrazit fotografie s klíčovým slovem", "Show Pins": "Zobrazit body", "Show Sidebar Section": "Zobrazit sekci postranního panelu", @@ -2394,5 +2386,24 @@ "End Slideshow": "Ukončit prezentaci", "Save Current Settings as New Effect Preset…": "Uložit aktuální nastavení jako nové přednastavení efektu…", "Manage Effect Presets…": "Spravovat přednastavení efektů…", - "Export Effect Presets…": "Exportovat přednastavení efektů…" + "Export Effect Presets…": "Exportovat přednastavení efektů…", + "Before/After Side by Side": "Před/Po vedle sebe", + "Clipping": "Ořezání tónů", + "Mask Overlay": "Překrytí masky", + "All Mask Overlays": "Všechna překrytí masek", + "Mask Pins": "Body masek", + "Photo Counts": "Počty fotografií", + "Focus Point": "Bod zaostření", + "Layout Image": "Obrázek rozvržení", + "Detail View": "Zobrazení detailu", + "Edit Panel": "Panel Úpravy", + "Close the open dialog, then drop the files again": "Zavřete otevřené okno, pak soubory pusťte znovu", + "Close the open dialog to drop files here": "Zavřete otevřené okno, aby sem šlo soubory pustit", + "Drop to open the face model": "Pusťte pro otevření modelu obličejů", + "Drop to import": "Pusťte pro import", + "Dropped Files": "Přetažené soubory", + "Import dropped files without review": "Importovat přetažené soubory bez kontroly", + "An import is running": "Import už běží", + "An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí", + "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky" } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index af29f7134..79390f136 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -10,7 +10,7 @@ " · {} not readable": " · {} nicht lesbar", " · {} original{} left at the source": " · Originale an der Quelle verblieben: {}{:.0}", " · {} restored from Recently Deleted": " · {} aus „Zuletzt gelöscht“ wiederhergestellt", - " — not carried over: {}{}": " — nicht übernommen: {}{}", + " — not carried over: {}{}": " – nicht übernommen: {}{}", "+{} more": "+{} weitere", ", {failed} file{} not readable": ", nicht lesbare Dateien: {failed}{:.0}", "; {kept} already had a location": "; bereits verortet: {kept}", @@ -54,7 +54,7 @@ "Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}", "Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}", "Exporting {} of {total}": "{} von {total} wird exportiert", - "Filter bar — {active} active filter{}": "Filterleiste — aktive Filter: {active}{:.0}", + "Filter bar — {active} active filter{}": "Filterleiste – aktive Filter: {active}{:.0}", "Focus {:.0}": "Schärfe {:.0}", "Folder “{}” (and its subfolders)": "Ordner „{}“ (und seine Unterordner)", "Found {n} missing photo{}; {left} still missing{unsure}": "Gefundene fehlende Fotos: {n}{:.0}; weiterhin fehlend: {left}{unsure}", @@ -63,11 +63,11 @@ "Import failed: {e}": "Import fehlgeschlagen: {e}", "Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}", "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Die XMP-Begleitdateien von {n} Fotos konnten nicht geschrieben werden (ist ihr Ordner offline?). LightCraft versucht es weiter.", - "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil „{profile}“ wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.", - "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.", - "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Guided-Upright-Transformation wird nicht unterstützt.", - "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Upright-Transformation wird nicht unterstützt.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil die Perspektivkorrektur aus ist.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Perspektivkorrektur mit Hilfslinien wird nicht unterstützt.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Perspektivkorrektur wird nicht unterstützt.", "XMP adjustments were not imported for {path}: {fields}.": "XMP-Anpassungen wurden für {path} nicht importiert: {fields}.", "Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}", "Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}", @@ -90,10 +90,10 @@ "Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?", "Named ({named_n})": "Benannt ({named_n})", "Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet", - "Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen — nur kopierte Einstellungen werden eingefügt", + "Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen – nur kopierte Einstellungen werden eingefügt", "Preset: {name}": "Vorgabe: {name}", - "Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau — das eingebettete Kamera-JPEG wird bearbeitet ({})", - "Preview only — {}": "Nur Vorschau — {}", + "Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau – das eingebettete Kamera-JPEG wird bearbeitet ({})", + "Preview only — {}": "Nur Vorschau – {}", "Previews ready for {done} photo{plural}": "Vorschauen für {done} Foto{plural} fertig", "Profile: {}": "Profil: {}", "Proof Copy ({})": "Softproof-Kopie ({})", @@ -101,7 +101,7 @@ "Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos", "Quick Develop: {name}": "Schnellentwicklung: {name}", "Rated {}": "Bewertet mit {}", - "Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen … {done} von {total}", + "Reading Lightroom catalog… {done} of {total}": "Lightroom-Katalog wird gelesen… {done} von {total}", "Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}", "Reading photos… {} of {}": "Fotos werden gelesen… {} von {}", "Redo {}": "Wiederholen: {}", @@ -129,7 +129,7 @@ "Undo {}": "Rückgängig: {}", "Unknown tag{s} {} stay{} as typed (see Tags)": "Unbekannte Platzhalter{s:.0} {} bleiben unverändert{:.0} (siehe „Platzhalter“)", "Updated “{name}”": "„{name}“ aktualisiert", - "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} – eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.", "Version: {}": "Version: {}", "Virtual copy “{name}” of {of}": "Virtuelle Kopie „{name}“ von {of}", "Working on {what} for {} photos…": "{what} für {} Fotos wird verarbeitet…", @@ -153,16 +153,16 @@ "{name} Off": "{name} aus", "{name} On": "{name} ein", "{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt", - "{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken", - "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}", - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}", - "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut", + "{name} — double-click to reset it": "{name} – zum Zurücksetzen doppelklicken", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} Änderung(en) konnten nicht auf den Datenträger geschrieben werden{0:.0}: {e}\nWenn du jetzt beendest, gehen sie verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} Änderung(en) nur im Arbeitsspeicher, nicht auf dem Datenträger gespeichert{0:.0}: {e}\nLightCraft versucht es automatisch erneut; wenn du jetzt beendest, gehen sie verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} Änderung(en) nur im Arbeitsspeicher gespeichert{:.0}: {e} – LightCraft versucht es erneut", "{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt", "{n} photos": "Fotos: {n}", - "{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)", + "{n} photos in {} are hidden by the filters above": "{n} Fotos in {} werden durch die obigen Filter ausgeblendet", "{n} photo{}": "{n} Foto{}", "{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", - "{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", + "{n} photo{} now · updates automatically as photos change": "Derzeit {n} Foto{} · wird bei Änderungen an Fotos automatisch aktualisiert", "{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten", "{pid} (missing)": "{pid} (fehlt)", "{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}", @@ -180,7 +180,7 @@ "{} files": "{} Dateien", "{} files and {f} folder{}": "{} Dateien und {f} Ordner{:.0}", "{} item{}": "Elemente: {}{:.0}", - "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien — entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien – entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.", "{} photos": "Fotos: {}", "{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV", "{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°", @@ -188,7 +188,7 @@ "{} sample{}": "Farbproben: {}{:.0}", "{} sec": "{} s", "{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto", - "{} — show their photos": "{} — Fotos dieser Person anzeigen", + "{} — show their photos": "{} – Fotos dieser Person anzeigen", "· {dups} already in the library": "· {dups} bereits in der Bibliothek", "· {n} photo{}": "· {n} Foto{}", "· {trashed} in Recently Deleted": "· {trashed} in „Zuletzt gelöscht“", @@ -196,17 +196,17 @@ "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.", "Filtered: {label}": "Gefiltert: {label}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht → Filter löschen zeigt alle Fotos", - "Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft", - "{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)", + "Moved; {} photo(s) relinked": "Verschoben; {} Foto(s) neu verknüpft", + "{} label (click more labels to show any of them)": "Farbmarkierung {} (weitere Farbmarkierungen auswählen, um alle passenden Fotos zu zeigen)", ", {} star{}": ", Sterne: {}{:.0}", - ", {} label": ", Markierung: {}", + ", {} label": ", Farbmarkierung: {}", "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Lädt von {host} herunter. Sobald es angekommen und geprüft ist, installiert LightCraft es und {after}.", "{:.1} MB": "{:.1} MB", "Running on the processor: {}.": "Läuft auf dem Prozessor: {}.", "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Im Ordner „denoise“ der Bibliothek gespeichert; bei Erreichen der Grenze werden zuerst die ältesten entfernt, die kein Foto verwendet.", "Denoising {name}: {done} of {total} tiles{}": "{name} wird entrauscht: {done} von {total} Kacheln{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} von {} von {host}", + "{} of {} from {host}": "{} von {} (Quelle: {host})", "{} KB": "{} KB", "{} MB": "{} MB", "Making the denoised picture… {}%": "Entrauschtes Bild wird berechnet… {}%", @@ -223,17 +223,18 @@ "Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.", "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…", + "Inside: {names}": "Darin: {names}", "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?", "There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?", "Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt", - "On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos", + "On {have} of {of} selected photos": "Bei {have} von {of} ausgewählten Fotos", "Remove “{name}”": "„{name}“ entfernen", "There is a keyword set “{name}” already": "Es gibt bereits einen Stichwortsatz „{name}“", "Added {n} keyword{}": "Stichwörter hinzugefügt: {n}{:.0}", "; {n} gained synonyms": "; mit neuen Synonymen: {n}", "Exported {n} keyword{}": "Stichwörter exportiert: {n}{:.0}", - " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}", - " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " – Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}", + " — left out, as a keyword list can't hold them: {names}": " – ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}", "{done} of {total}": "{done} von {total}", "{done} of {total} MB": "{done} von {total} MB", "{pct} %": "{pct} %", @@ -254,7 +255,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", " in {g} group{}": " in {g} Gruppe(n){:.0}", " · kept, not applied: {}": " · behalten, nicht angewendet: {}", - " — kept but not applied: {}": " — behalten, aber nicht angewendet: {}", + " — kept but not applied: {}": " – behalten, aber nicht angewendet: {}", "Apply to “{mask}” on Selected Photos": "Auf „{mask}“ in den ausgewählten Fotos anwenden", "Delete the effect preset “{name}”?": "Effektvorgabe „{name}“ löschen?", "Delete these {n} effect presets?": "Diese {n} Effektvorgaben löschen?", @@ -280,7 +281,7 @@ "Skipped {problem}": "Übersprungen: {problem}", "…and {more} more": "…und {more} weitere", "Template preview: {name}": "Vorlagenvorschau: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Folien · {time}", + "{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Dias · {time}", "{n} pages": "{n} Seiten", "{n} JPEG files": "{n} JPEG-Dateien", "{s} s of AV1 video": "{s} s AV1-Video", @@ -289,7 +290,7 @@ "{n} raws can now be developed from their raw data": "{n} RAW-Dateien können jetzt aus ihren Rohdaten entwickelt werden", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft wurde unerwartet beendet: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", + "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesicht(er) in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat", "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben", "Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?", "Listing folders — folders: {folders}, files: {files}": "Ordner werden aufgelistet – Ordner: {folders}, Dateien: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück.", + "Drop to review {n} item{} for import": "Ablegen, um vor dem Import zu prüfen (Objekte: {n}){:.0}", + "Drop to import {n} item{}": "Ablegen zum Importieren (Objekte: {n}){:.0}", + "Drop to import {n} preset{}": "Ablegen zum Importieren von Vorgaben: {n}{:.0}", + "Could not read {name}: {e}": "{name} konnte nicht gelesen werden: {e}", + "Added {n} photo{}": "{n} Foto{} hinzugefügt", + "Where should the Lightroom catalog “{catalog}” go?": "Wohin soll der Lightroom-Katalog „{catalog}“?", + "Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index cfed522cf..679e3b00f 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -14,7 +14,7 @@ "0: nothing is rejected.": "0: Nichts wird abgelehnt.", "1 day": "1 Tag", "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 auf studio-mac. Beide Programme können nicht gleichzeitig schreiben und gegenseitig Bearbeitungen verlieren. Ein Absturz lässt die", - "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Beschnitt", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Zuschnitt", "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 großen Vorschau. Metadaten werden importiert: Aufnahmezeit, Kamera, Objektiv, Belichtung, GPS und XMP. Die RAW-Daten", "2026-04 or 2026-04-12": "2026-04 oder 2026-04-12", "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 neue eingebaute Vorgaben: Porträt, Landschaft, Stadt, Essen, Jahreszeiten, Vintage und Schwarzweißtonungen.", @@ -64,7 +64,7 @@ "Add/Subtract/Intersect Mask": "Maske hinzufügen/subtrahieren/schneiden", "Added to photos you import that don't already have it.": "Wird importierten Fotos hinzugefügt, die diese Angabe noch nicht enthalten.", "Adding": "Wird hinzugefügt", - "Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt …", + "Adding Lightroom photos…": "Lightroom-Fotos werden hinzugefügt…", "Advanced search…": "Erweiterte Suche…", "After": "Nachher", "After 30 days": "Nach 30 Tagen", @@ -99,9 +99,9 @@ "Aqua Hue": "Aquamarin – Farbton", "Aqua Luminance": "Aquamarin – Luminanz", "Aqua Saturation": "Aquamarin – Sättigung", - "Arrange Panel": "Bereich anordnen", + "Arrange Panel": "Bedienfeld anordnen", "ArtCraft Website": "ArtCraft-Website", - "ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps", + "ArtCraft — more creative apps": "ArtCraft – weitere Kreativ-Apps", "As Shot": "Wie Aufnahme", "As shot": "Wie Aufnahme", "Ascending": "Aufsteigend", @@ -235,7 +235,6 @@ "Clear Filters": "Filter löschen", "Clear Guides": "Hilfslinien löschen", "Clear History": "Verlauf löschen", - "Clear Preview Cache": "Vorschau-Cache leeren", "Clear Quick Collection": "Schnellsammlung leeren", "Clear Reference": "Referenz entfernen", "Clear all": "Alles löschen", @@ -258,7 +257,7 @@ "Close Window": "Fenster schließen", "Close all": "Alle schließen", "Close file": "Datei schließen", - "Close panel": "Bereich schließen", + "Close panel": "Bedienfeld schließen", "Cloudy": "Bewölkt", "Cmd+Shift+R, keeps the photo and clears its edits.)": "mit ⌘⇧R die Bearbeitungen zurück und behält das Foto.", "Collapse": "Einklappen", @@ -289,7 +288,6 @@ "Company": "Unternehmen", "Compare": "Vergleichen", "Compare (Shift+C)": "Vergleichen (Umschalt+C)", - "Compare Before and After": "Vorher und Nachher vergleichen", "Compare needs at least two photos": "Zum Vergleichen sind mindestens zwei Fotos erforderlich", "Component options": "Komponentenoptionen", "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimierte Nikon-NEFs, verlustfrei oder verlustbehaftet mit 12 oder 14 Bit, etwa D3200, D5100, D7000, D750, D850 und Z 50,", @@ -298,10 +296,10 @@ "Confirm before deleting": "Vor dem Löschen bestätigen", "Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen", "Move Photos": "Fotos verschieben", - "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Die Dateien der Fotos und ihre XMP-Begleitdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.", "Don't ask again": "Nicht mehr fragen", "Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben", - "Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet die Abfrage wieder ein", "Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen", "Constrain Crop": "Zuschnitt beschränken", "Contact Sheet PDF": "Kontaktabzug-PDF", @@ -465,7 +463,7 @@ "Edit Date": "Bearbeitungsdatum", "Edit Info": "Informationen bearbeiten", "Edit Label Names": "Namen der Farbmarkierungen bearbeiten", - "Edit Label Names…": "Markierungsnamen bearbeiten…", + "Edit Label Names…": "Namen der Farbmarkierungen bearbeiten…", "Edit Mask": "Maske bearbeiten", "Edit Mask Component": "Maskenkomponente bearbeiten", "Edit Sections": "Bearbeitungsbereiche", @@ -507,7 +505,7 @@ "Export Presets…": "Vorgaben exportieren…", "Export User Presets…": "Benutzervorgaben exportieren…", "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Exportdateinamen verwenden die Platzhalter der Fotoumbenennung ({title}, {seq:2}, {date:%Y-%m-%d}…) sowie {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", - "Export stopped unexpectedly": "Export unerwartet angehalten", + "Export stopped unexpectedly": "Export unerwartet abgebrochen", "Export with Preset": "Mit Vorgabe exportieren", "Export with Previous": "Mit vorherigen Einstellungen exportieren", "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Exportdateien werden zunächst temporär geschrieben und anschließend umbenannt. Ein voller Datenträger oder getrenntes Laufwerk", @@ -577,7 +575,7 @@ "Flip Overlay (⇧O)": "Überlagerung spiegeln (⇧O)", "Flip V": "V spiegeln", "Flip Vertical": "Vertikal spiegeln", - "Float panel": "Bereich frei platzieren", + "Float panel": "Bedienfeld frei platzieren", "Flow": "Fluss", "Fluorescent": "Leuchtstofflicht", "Focal Length": "Brennweite", @@ -593,7 +591,7 @@ "Form fields panel": "Formularfeldbereich", "Format": "Format", "Forward": "Vorwärts", - "Frame time": "Bilddauer", + "Frame time": "Frame-Zeit", "Frame update": "Bildaktualisierung", "Free": "Frei", "Fresh & Bright": "Frisch und hell", @@ -698,7 +696,7 @@ "Import": "Importieren", "Import Date": "Importdatum", "Import Lightroom Catalog": "Lightroom-Katalog importieren", - "Import Lightroom Catalog…": "Lightroom-Katalog importieren …", + "Import Lightroom Catalog…": "Lightroom-Katalog importieren…", "Import Photos": "Fotos importieren", "Import Photos…": "Fotos importieren…", "Import Point Curve Presets": "Punktkurvenvorgaben importieren", @@ -724,7 +722,7 @@ "In Photo Grid and Square Grid: expand or collapse the stack": "In Fotoraster und quadratischem Raster: Stapel auf- oder zuklappen", "In Recently Deleted": "In „Zuletzt gelöscht“", "In library": "In Bibliothek", - "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", + "In memory — nothing is saved": "Im Arbeitsspeicher – nichts wird gespeichert", "In use": "In Verwendung", "In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.", "Include selected photos": "Ausgewählte Fotos einschließen", @@ -805,7 +803,7 @@ "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).", "LightCraft on GitHub": "LightCraft auf GitHub", "LightCraft website": "LightCraft-Website", - "LightCraft — Second Window": "LightCraft — zweites Fenster", + "LightCraft — Second Window": "LightCraft – zweites Fenster", "Lightroom Classic Catalog": "Lightroom Classic-Katalog", "Lightroom import cancelled": "Lightroom-Import abgebrochen", "Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen", @@ -826,7 +824,7 @@ "Loading preview…": "Vorschau wird geladen…", "Local": "Lokal", "Local folders": "Lokale Ordner", - "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", + "Local library — no cloud account needed": "Lokale Bibliothek – kein Cloud-Konto erforderlich", "Locate Missing File…": "Fehlende Datei suchen…", "Locate Missing Files…": "Fehlende Dateien suchen…", "Locate Photo": "Foto suchen", @@ -882,7 +880,7 @@ "Midtones Hue": "Mitteltöne – Farbton", "Midtones Luminance": "Mitteltöne – Luminanz", "Midtones Saturation": "Mitteltöne – Sättigung", - "Midtones split": "Mitteltontrennung", + "Midtones split": "Mitteltongrenze", "Minimize": "Minimieren", "Missing Photos": "Fehlende Fotos", "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Fehlende Metadaten ({camera}, {title}…) hinterlassen eine Lücke; bei einem leeren Ergebnis bleibt der Originalname erhalten.", @@ -924,7 +922,7 @@ "Muted": "Gedämpft", "Muted Film": "Gedämpfter Film", "My Photos": "Meine Fotos", - "My Photos Panel": "Bereich „Meine Fotos“", + "My Photos Panel": "Bedienfeld „Meine Fotos“", "Name": "Name", "Name (A–Z)": "Name (A–Z)", "Name of the folder the original is in": "Name des Ordners mit dem Original", @@ -981,7 +979,7 @@ "No photo selected": "Kein Foto ausgewählt", "No photos": "Keine Fotos", "Your library is empty": "Deine Bibliothek ist leer", - "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Nichts wird kopiert oder verschoben, außer du wählst es.", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Kopiert oder verschoben wird nur, wenn du dich dafür entscheidest.", "or drop photos and folders into this window": "oder ziehe Fotos und Ordner in dieses Fenster", "Import photos to get started": "Importiere Fotos, um loszulegen", "or drop photos into this window": "oder ziehe Fotos in dieses Fenster", @@ -995,7 +993,7 @@ "No versions yet. Create one to keep this look.": "Noch keine Versionen. Erstelle eine, um diesen Look zu bewahren.", "Noise": "Rauschen", "Noise Reduction": "Rauschreduzierung", - "Non-raw photos": "Andere Fotos als RAW", + "Non-raw photos": "Nicht-RAW-Fotos", "None": "Keine", "Not Now": "Nicht jetzt", "Nothing to compare": "Nichts zum Vergleichen", @@ -1039,11 +1037,11 @@ "Overwrite": "Überschreiben", "Pages": "Seiten", "Paint": "Auftragen", - "Paint only areas like the one under the brush": "Nur Bereiche wie den unter dem Pinsel bearbeiten", + "Paint only areas like the one under the brush": "Nur Bereiche bemalen, die dem Bereich unter dem Pinsel ähneln", "Paint over a distraction on the photo to remove it.": "Übermale ein störendes Element im Foto, um es zu entfernen.", "Paint overlay": "Überlagerung auftragen", "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW-Dateien von Panasonic und Leica, RW2, RWL und ältere RAW-Dateien von DMC-LX1 bis DC-S1R II, werden in", - "Panels": "Bereiche", + "Panels": "Bedienfelder", "Panorama": "Panorama", "Panorama Merge": "Zu Panorama zusammenfügen", "Panorama Merge Preview": "Panoramavorschau", @@ -1107,7 +1105,7 @@ "Presets (Shift+P)": "Vorgaben (Umschalt+P)", "Presets and profiles": "Vorgaben und Profile", "Presets ▾": "Vorgaben ⌄", - "Press keys…": "Tasten drücken …", + "Press keys…": "Tasten drücken…", "Preview": "Vorschau", "Preview Build Progress": "Fortschritt der Vorschauerstellung", "Preview only": "Nur Vorschau", @@ -1176,7 +1174,7 @@ "Read mode": "Lesemodus", "Read the SAM License": "SAM-Lizenz lesen", "Reading": "Wird gelesen", - "Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen …", + "Reading Lightroom catalog…": "Lightroom-Katalog wird gelesen…", "Reading folder…": "Ordner wird gelesen…", "Real name": "Echter Name", "Recent": "Zuletzt verwendet", @@ -1207,6 +1205,9 @@ "Refresh View": "Ansicht aktualisieren", "Reject": "Ablehnen", "Reject below focus": "Unter Schärfegrenzwert ablehnen", + "Burst: shots at most this far apart": "Serie: Aufnahmen höchstens so weit auseinander", + "Burst: how alike (loose … strict)": "Serie: wie ähnlich (locker … streng)", + "Lower it when a panning or moving burst splits into several groups.": "Senken, wenn eine Mitzieher- oder Bewegungsserie in mehrere Gruppen zerfällt.", "Rejected": "Abgelehnt", "Rejects": "Abgelehnte Fotos", "Reliability": "Zuverlässigkeit", @@ -1218,7 +1219,7 @@ "Remove CA": "Chromatische Aberration entfernen", "Remove Chromatic Aberration": "Chromatische Aberration entfernen", "Camera local tone mapping": "Lokale Tonwertzuordnung der Kamera", - "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der Raw-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der RAW-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert", "Remove Disk from Library": "Datenträger aus Bibliothek entfernen", "Remove Disk from Library…": "Datenträger aus Bibliothek entfernen…", "Remove Face Box": "Gesichtsrahmen entfernen", @@ -1268,7 +1269,7 @@ "Reset Control": "Regler zurücksetzen", "Reset Crop": "Zuschnitt zurücksetzen", "Reset Edits": "Bearbeitungen zurücksetzen", - "Reset Panel Layout": "Bereichsanordnung zurücksetzen", + "Reset Panel Layout": "Bedienfeldanordnung zurücksetzen", "Reset Parametric Curve": "Parametrische Kurve zurücksetzen", "Reset Section": "Bereich zurücksetzen", "Reset Tone Curve": "Gradationskurve zurücksetzen", @@ -1276,8 +1277,8 @@ "Reset all edits": "Alle Bearbeitungen zurücksetzen", "Reshuffle": "Neu mischen", "Resize Face Box": "Gesichtsrahmengröße ändern", - "Resize panel window": "Größe des Bereichsfensters ändern", - "Resize panels": "Bereichsgrößen ändern", + "Resize panel window": "Größe des Bedienfeldfensters ändern", + "Resize panels": "Bedienfeldgrößen ändern", "Resize to": "Größe ändern auf", "Resolution (ppi)": "Auflösung (ppi)", "Restore": "Wiederherstellen", @@ -1328,7 +1329,7 @@ "Save as set": "Als Satz speichern", "Save as…": "Speichern unter…", "Save preset": "Vorgabe speichern", - "Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …", + "Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert…", "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine", "Scale": "Skalierung", "Scan failed": "Durchsuchen fehlgeschlagen", @@ -1383,7 +1384,7 @@ "Set as Target Album (B adds to it)": "Als Zielalbum festlegen (B fügt Fotos hinzu)", "Set as Top of Stack": "Als oberstes Foto im Stapel festlegen", "Set date & time": "Datum und Uhrzeit festlegen", - "Set name": "Satzname", + "Set name": "Name des Satzes", "Set the mix from the photo's colours": "Mischung anhand der Farben des Fotos festlegen", "Settings": "Einstellungen", "Settings file problem": "Problem mit Einstellungsdatei", @@ -1398,7 +1399,7 @@ "Shadows Hue": "Tiefen – Farbton", "Shadows Luminance": "Tiefen – Luminanz", "Shadows Saturation": "Tiefen – Sättigung", - "Shadows Tint": "Tiefen – Tonung", + "Shadows Tint": "Tiefen – Tönung", "Shadows split": "Tiefengrenze", "Sharpen": "Schärfen", "Sharpening": "Schärfen", @@ -1408,17 +1409,12 @@ "Short Edge": "Kurze Kante", "Show": "Anzeigen", "Show All": "Alle einblenden", - "Show Clipping": "Beschnitt anzeigen", "Show Deghost Overlay": "Geisterbildüberlagerung anzeigen", "Show Hidden Local Locations": "Ausgeblendete lokale Orte anzeigen", "Show Luminance Map": "Luminanzkarte anzeigen", "Show Mask": "Maske anzeigen", - "Show Mask Overlay": "Maskenüberlagerung anzeigen", - "Show All Mask Overlays": "Alle Maskenüberlagerungen anzeigen", "Show all overlays": "Alle Überlagerungen anzeigen", - "Show Mask Pins": "Maskenpins anzeigen", "Show Original": "Original anzeigen", - "Show Photo Counts": "Fotoanzahl anzeigen", "Show Photos with Keyword": "Fotos mit Stichwort anzeigen", "Show Pins": "Pins anzeigen", "Show Source": "Quelle anzeigen", @@ -1438,7 +1434,7 @@ "Show the import review when a camera or card is connected": "Importfenster öffnen, wenn eine Kamera oder Karte angeschlossen wird", "Show the template tags; click one to insert it": "Vorlagenplatzhalter anzeigen; zum Einfügen anklicken", "Show/Hide Mask": "Maske ein-/ausblenden", - "Show/hide My Photos panel": "Bereich „Meine Fotos“ ein-/ausblenden", + "Show/hide My Photos panel": "Bedienfeld „Meine Fotos“ ein-/ausblenden", "Shutter speed": "Verschlusszeit", "Side panels": "Seitliche Bedienfelder", "Sidecar names": "Namen der Begleitdateien", @@ -1532,13 +1528,13 @@ "Temp": "Temp.", "Temperature": "Temperatur", "Template": "Vorlage", - "Temporary session — your library isn't open, and nothing you do here is saved.": "Vorübergehende Sitzung — deine Bibliothek ist nicht geöffnet; hier vorgenommene Änderungen werden nicht gespeichert.", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Vorübergehende Sitzung – deine Bibliothek ist nicht geöffnet; hier vorgenommene Änderungen werden nicht gespeichert.", "Text": "Text", "Text direction": "Textrichtung", "Texture": "Struktur", "That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.", "That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden", - "The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmasken sind verfügbar.", "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek", "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält", "The download didn't work:": "Der Download ist fehlgeschlagen:", @@ -1558,7 +1554,7 @@ "Thumbnail cache": "Miniaturbild-Cache", "Thursday": "Donnerstag", "Time zone": "Zeitzone", - "Tint": "Tonung", + "Tint": "Tönung", "Title": "Titel", "Title metadata": "Titel aus Metadaten", "Toggle Keyword from Set": "Stichwort aus Satz umschalten", @@ -1587,7 +1583,7 @@ "Unflagged": "Unmarkiert", "Ungroup Stack": "Stapel auflösen", "Unknown Date": "Unbekanntes Datum", - "Unknown tags stay as typed — check the preview for a {typo}.": "Unbekannte Platzhalter bleiben unverändert — prüfe die Vorschau auf einen {typo}.", + "Unknown tags stay as typed — check the preview for a {typo}.": "Unbekannte Platzhalter bleiben unverändert – prüfe die Vorschau auf einen {typo}.", "Unrated": "Unbewertet", "Unreadable": "Nicht lesbar", "Update": "Aktualisieren", @@ -1639,7 +1635,7 @@ "Watermark": "Wasserzeichen", "Wednesday": "Mittwoch", "What's New": "Neuigkeiten", - "When rated or hovered": "Bei Bewertung oder Mauszeigerkontakt", + "When rated or hovered": "Bei Bewertung oder unter dem Mauszeiger", "White Balance": "Weißabgleich", "White Balance Selector": "Weißabgleich-Pipette", "White Balance Selector (W)": "Weißabgleich-Pipette (W)", @@ -1647,7 +1643,7 @@ "Width": "Breite", "Width (% of photo)": "Breite (% des Fotos)", "Width (px)": "Breite (px)", - "Width & Height": "Breite & Höhe", + "Width & Height": "Breite und Höhe", "Will be replaced": "Wird ersetzt", "Will be restored": "Wird wiederhergestellt", "Window": "Fenster", @@ -1675,7 +1671,7 @@ "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`, zusammen mit ihren XMP-Begleitdateien. Jedes Original verlässt die Karte erst nach", "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`. Auf Laufwerken ohne diese Unterscheidung wird die Schreibweise weiterhin geändert.", "a full drive) no longer counts as built and Build Smart Previews replaces it.": "oder vollen Laufwerk gilt nicht mehr als fertig und wird beim Erstellen der Smart-Vorschauen ersetzt.", - "a literal %": "ein wörtliches %", + "a literal %": "ein %-Zeichen", "a removed photo": "einem entfernten Foto", "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "(Issue #101). Vor einem erneuten Versuch wird das Schreibfragment entfernt; beim nächsten Start werden alle Änderungen wiederhergestellt.", "again. Change the period (or turn it off) in Settings → Performance.": "wieder. Der Zeitraum lässt sich unter Einstellungen → Leistung ändern oder deaktivieren.", @@ -1753,7 +1749,7 @@ "isn't in the last": "liegt nicht in den letzten", "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "statt sie der anderen Datei wegzunehmen (Issue #92). Die Suche fehlender Fotos findet umbenannte Dateien anhand ihres Inhalts und bevorzugt passende Inhalte", "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "mit Fotos und XMP-Begleitdateien zurückverschoben; die Fotos verweisen wieder darauf. Ist der alte Speicherort inzwischen belegt,", - "keep names — or e.g. {date}_{seq:3}": "Namen beibehalten — oder z. B. {date}_{seq:3}", + "keep names — or e.g. {date}_{seq:3}": "Namen beibehalten – oder z. B. {date}_{seq:3}", "latitude, longitude": "Breitengrad, Längengrad", "layout is read from each file instead of assumed.": "wird aus jeder Datei gelesen, statt vorausgesetzt zu werden.", "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "hinterlässt kein DNG und behält die RAW-Datei. Smart-Vorschauen verwenden denselben Schreibweg; eine beschädigte Vorschau nach einem Absturz", @@ -1843,7 +1839,7 @@ "Any camera": "Jede Kamera", "Any lens": "Jedes Objektiv", "Any keyword": "Jedes Stichwort", - "Label Set": "Markierungssatz", + "Label Set": "Farbmarkierungssatz", ", picked": ", ausgewählt", ", rejected": ", abgelehnt", "Preview only: editing the camera's embedded JPEG": "Nur Vorschau: Bearbeitung des eingebetteten JPEGs der Kamera", @@ -1886,7 +1882,7 @@ "Make the denoised picture of the photos I am looking at": "Entrauschtes Bild der angezeigten Fotos berechnen", "Next up for AI Denoise…": "Als Nächstes beim KI-Entrauschen…", "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nichts wird geladen, bevor du zustimmst. Das Modell bleibt nur auf diesem Computer; LightCraft lädt es nie hoch und gibt es nie weiter.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Nur Fotos mit einer KI-Entrauschungsstärke über 0 werden berechnet. Exporte berechnen ihres immer, wenn es fehlt.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Nur Fotos mit einer KI-Entrauschungsstärke über 0 werden berechnet. Beim Export wird ein fehlendes Bild immer berechnet.", "Open page": "Seite öffnen", "Open the model's page": "Seite des Modells öffnen", "Opens the model's own page in your browser, to read about it or get the file yourself.": "Öffnet die Seite des Modells im Browser, um mehr darüber zu lesen oder die Datei selbst herunterzuladen.", @@ -1910,7 +1906,7 @@ "Unknown licence": "Unbekannte Lizenz", "Use": "Verwenden", "Waiting to start…": "Wartet auf Start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funktioniert mit Raw-Dateien von Kameras mit Standardsensor (Bayer). Andere Dateien behalten ihre normale Rauschreduzierung.", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funktioniert mit RAW-Dateien von Kameras mit Standardsensor (Bayer). Andere Dateien behalten ihre normale Rauschreduzierung.", "cached picture": "zwischengespeichertes Bild", "cached pictures": "zwischengespeicherte Bilder", "commercial use allowed": "kommerzielle Nutzung erlaubt", @@ -1992,20 +1988,20 @@ "Sa": "Sa", "Su": "So", "Pick a date": "Datum wählen", - "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen", + "would make this album include itself through other smart albums": "würde dazu führen, dass dieses Album sich über andere Smart-Alben selbst enthält", "Search albums": "Alben suchen", "No albums match": "Kein Album passt", "This is the album you're editing": "Das ist das Album, das du bearbeitest", - "It tests this album, so testing it back would loop": "Es prüft dieses Album; es zurückzuprüfen wäre ein Kreis", - "Copy Name": "Kopiename", + "It tests this album, so testing it back would loop": "Es prüft bereits dieses Album; eine Prüfung in Gegenrichtung ergäbe eine Endlosschleife", + "Copy Name": "Name der Kopie", "In a Stack": "In einem Stapel", "File Extension": "Dateiendung", "Video Duration": "Videodauer", "Keyword Count": "Anzahl Stichwörter", "People Count": "Anzahl Personen", - "Shutter Speed": "Belichtungszeit", + "Shutter Speed": "Verschlusszeit", "Cropped": "Zugeschnitten", - "Keywords & People": "Stichwörter & Personen", + "Keywords & People": "Stichwörter und Personen", "Description": "Beschreibung", "Camera Info": "Kamera-Info", "Develop": "Bearbeitung", @@ -2016,7 +2012,7 @@ "Whole names": "Vollständige Namen", "Grouped by parent": "Nach übergeordnetem Stichwort gruppiert", "Keywords of the selection": "Stichwörter der Auswahl", - "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter im selben Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter mit demselben übergeordneten Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.", "Show every keyword of the selected photos": "Alle Stichwörter der ausgewählten Fotos anzeigen", "new keyword": "neues Stichwort", "Return creates this keyword and gives it to the selected photos": "Die Eingabetaste erstellt dieses Stichwort und weist es den ausgewählten Fotos zu", @@ -2032,14 +2028,14 @@ "Create Keyword Tag": "Stichwort erstellen", "Edit Keyword Tag": "Stichwort bearbeiten", "Create": "Erstellen", - "No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht.", + "No photo has it; the keywords below it go too.": "Kein Foto verwendet es; die Stichwörter darunter werden ebenfalls gelöscht.", "Edit Keyword Tag…": "Stichwort bearbeiten…", "Put New Keywords Inside This Keyword": "Neue Stichwörter in dieses Stichwort einfügen", "Purge Unused Keywords": "Nicht verwendete Stichwörter löschen", "Delete Keyword…": "Stichwort löschen…", "Drop here for the top level": "Hier ablegen für die oberste Ebene", - "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen.", - "Select photos to give them keywords.": "Wähle Fotos aus, um ihnen Stichwörter zu geben.", + "Their photos and the keywords below them come together under one keyword.": "Die Fotos und die untergeordneten Stichwörter werden unter einem Stichwort zusammengeführt.", + "Select photos to give them keywords.": "Wähle Fotos aus, um ihnen Stichwörter zuzuweisen.", "Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen", "Will Export": "Wird exportiert", "No keywords are exported": "Es werden keine Stichwörter exportiert", @@ -2059,14 +2055,14 @@ "Synchronize Folder": "Ordner synchronisieren", "Synchronize": "Synchronisieren", "Scanning…": "Wird durchsucht…", - "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Filialdateien", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Begleitdateien", "Bring the library up to date with this folder and the folders inside it:": "Die Bibliothek mit diesem Ordner und den Ordnern darin abgleichen:", "Import new photos": "Neue Fotos importieren", "Remove missing photos from the library": "Fehlende Fotos aus der Bibliothek entfernen", - "Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Filialdateien lesen", + "Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Begleitdateien lesen", "The library is up to date with this folder.": "Die Bibliothek ist mit diesem Ordner auf dem neuesten Stand.", "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Fehlende Fotos werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.", - "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Begleitdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.", "Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind", "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.", @@ -2105,7 +2101,7 @@ "Discarding smart previews": "Smart-Vorschauen werden verworfen", "Importing Lightroom catalog": "Lightroom-Katalog wird importiert", "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen", - "Merging": "Zusammenfügen", + "Merging": "Wird zusammengefügt", "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", @@ -2153,7 +2149,7 @@ "Move to Folder": "In Ordner verschieben", "Move to Folder…": "In Ordner verschieben…", "Move Selected Photos Here": "Ausgewählte Fotos hierher verschieben", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Begleitdateien in diesen Ordner; nichts wird überschrieben", "Appearance Settings…": "Erscheinungsbild-Einstellungen…", "Add to favorites": "Zu Favoriten hinzufügen", "Remove from favorites": "Aus Favoriten entfernen", @@ -2170,7 +2166,7 @@ "Detect Faces": "Gesichter erkennen", "Detect Faces (Photo menu) needs it: a small one-time download.": "„Gesichter erkennen“ (Menü „Foto“) benötigt es: ein kleiner, einmaliger Download.", "Download Face Model": "Gesichtsmodell herunterladen", - "Drag Crop Handle": "Freistellungsgriff ziehen", + "Drag Crop Handle": "Zuschnittgriff ziehen", "Evaluate Face Recognition": "Gesichtserkennung bewerten", "Face Model Downloads": "Downloads von Gesichtsmodellen", "Face Models": "Gesichtsmodelle", @@ -2219,7 +2215,7 @@ "Right": "Rechts", "Top": "Oben", "Bottom": "Unten", - "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite der Folie, damit Folien auf jedem Bildschirm gleich aussehen.", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite des Dias, damit Dias auf jedem Bildschirm gleich aussehen.", "Show guides": "Hilfslinien anzeigen", "Aspect preview": "Seitenverhältnis-Vorschau", "Zoom to fill frame": "Zoomen, um Rahmen zu füllen", @@ -2233,15 +2229,15 @@ "Background image": "Hintergrundbild", "(not in the library)": "(nicht in der Bibliothek)", "Use selected photo": "Ausgewähltes Foto verwenden", - "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wähle ein Foto aus und schalte dies ein, um die Folie hinter jedem Foto damit zu füllen.", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wähle ein Foto aus und schalte dies ein, um das Dia hinter jedem Foto damit zu füllen.", "Reset layout": "Layout zurücksetzen", - "Select a photo to preview its slide": "Wähle ein Foto, um seine Folie in der Vorschau zu sehen", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.", + "Select a photo to preview its slide": "Wähle ein Foto, um sein Dia in der Vorschau zu sehen", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Dia. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.", "Overlays": "Überlagerungen", "Identity plate": "Erkennungstafel", "Your name or studio": "Dein Name oder Studio", "Anchored to": "Verankert an", - "Slide": "Folie", + "Slide": "Dia", "Render behind image": "Hinter dem Bild rendern", "Rating stars": "Sternebewertung", "Text overlays": "Textüberlagerungen", @@ -2286,7 +2282,7 @@ "Shooting Data": "Aufnahmedaten", "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Jedes Foto so groß wie möglich auf Schwarz, mit kurzer Überblendung. Nichts darüber gezeichnet.", "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Eine hellgraue Wand mit weißem Passepartout und weichem Schatten; Bildunterschrift und Bewertung unter dem Foto.", - "Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm beschnitten, mit langsamen Überblendungen.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm zugeschnitten, mit langsamen Überblendungen.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Ein 16:9-Bild mit schwarzen Balken oben und unten, dem Titel in der Ecke, Blenden über Schwarz und einer Abschlusstafel.", "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto.", "Exporting slideshow": "Diashow wird exportiert", @@ -2299,11 +2295,11 @@ "Export JPEG…": "JPEG exportieren…", "Export Video…": "Video exportieren…", "Automatically show full screen": "Automatisch im Vollbild anzeigen", - "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Folie (lange Seite in Pixeln = Punkte), mit Foliendauer und Überblendung für den Präsentationsmodus des Betrachters.", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Dia (lange Seite in Pixeln = Punkte), mit Dia-Dauer und Überblendung für den Präsentationsmodus des Betrachters.", "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Nummerierte JPEG-Dateien (name-001.jpg…) in einem neuen Ordner, der im nächsten Schritt benannt wird.", - "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jede Folie für ihre Dauer mit ihren Überblendungen. Kein Ton: Musik wird noch nicht exportiert.", - "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Folien verwenden Layout, Überlagerungen sowie Intro- und Abspannbild der Diashow, in der Reihenfolge der Ansicht.", - "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Folien, mit den Einstellungen oben. Der Fortschritt erscheint in der Aktivitätsliste.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jedes Dia für seine Dauer mit seinen Überblendungen. Kein Ton: Musik wird noch nicht exportiert.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Dias verwenden Layout, Überlagerungen sowie Intro- und Abschlussbildschirm der Diashow, in der Reihenfolge der Ansicht.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Dias, mit den Einstellungen oben. Der Fortschritt erscheint im Aktivitätsstapel.", "Checking raw files": "RAW-Dateien werden geprüft", "1 raw can now be developed from its raw data": "1 RAW-Datei kann jetzt aus ihren Rohdaten entwickelt werden", "This raw is now developed from its raw data": "Diese RAW-Datei wird jetzt aus ihren Rohdaten entwickelt", @@ -2314,9 +2310,8 @@ "Same folder as original photo": "Im selben Ordner wie das Originalfoto", "Tidying the thumbnail cache": "Miniaturcache wird aufgeräumt", "Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändere ihre Regeln oder verwende Foto → Foto löschen", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Smart-Alben aktualisieren sich selbst: Ändere ihre Regeln oder verwende Foto → Foto löschen", "LightCraft stopped": "LightCraft wurde beendet", - "Show Focus Point": "Fokuspunkt anzeigen", "Zoom to Focus Point": "Auf Fokuspunkt zoomen", "Focus": "Fokus", "Focus point": "Fokuspunkt", @@ -2341,7 +2336,7 @@ "Detection": "Erkennung", "Recognition": "Wiedererkennung", "Not loaded yet": "Noch nicht geladen", - "Checking the GPU…": "GPU wird geprüft …", + "Checking the GPU…": "GPU wird geprüft…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.", "Checking the GPU for faces": "GPU für Gesichter wird geprüft", @@ -2467,8 +2462,8 @@ "Edit in HDR: highlights above SDR white, for HDR exports and displays": "In HDR bearbeiten: Lichter über SDR-Weiß, für HDR-Exporte und -Bildschirme", "SDR": "SDR", "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "Links: der Standardbereich (SDR) bis SDR-Weiß. Rechts: der HDR-Bereich, ein Band pro Blendenstufe über SDR-Weiß (die orange Linie: der HDR-Spielraum). Mit eingeschaltetem „HDR visualisieren“ übernehmen die Bänder dessen Farben.", - "Click to hide clipping warnings for shadows and highlights.": "Klicken, um die Beschnittwarnungen für Tiefen und Lichter auszublenden.", - "Click to show clipping warnings for shadows and highlights.": "Klicken, um die Beschnittwarnungen für Tiefen und Lichter einzublenden.", + "Click to hide clipping warnings for shadows and highlights.": "Klicken, um die Beschneidungswarnungen für Tiefen und Lichter auszublenden.", + "Click to show clipping warnings for shadows and highlights.": "Klicken, um die Beschneidungswarnungen für Tiefen und Lichter einzublenden.", "Curve channel": "Kurvenkanal", "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Gezielte Anpassung aktiv: auf dem Foto nach oben oder unten ziehen · Esc beendet", "Color wheel": "Farbrad", @@ -2509,7 +2504,7 @@ "Show mask": "Maske anzeigen", "Overlay color": "Überlagerungsfarbe", "Rename Person…": "Person umbenennen…", - "Their photos in the grid": "Ihre Fotos im Raster", + "Their photos in the grid": "Fotos dieser Person im Raster", "Tool Overlay": "Werkzeugüberlagerung", "When pins show on the photo (Shift+H cycles)": "Wann Pins auf dem Foto erscheinen (Umschalt+H wechselt)", "External Editing": "Externe Bearbeitung", @@ -2531,7 +2526,7 @@ "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Die in den Farbeinstellungen deines Desktops zugewiesenen Profile liegen meist in ~/.local/share/icc.", "Slide duration": "Dia-Dauer", "Fade duration": "Überblendungsdauer", - "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Ein Dia bleibt für seine Dauer stehen, die Überblendung eingeschlossen. Eine Überblendung von 0 schneidet hart.", + "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Ein Dia bleibt für seine Dauer stehen, die Überblendung eingeschlossen. Bei 0 wird hart geschnitten.", "Fade": "Überblendung", "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).": "Berechnet die nächsten Dias, während das aktuelle zu sehen ist, sodass Überblendungen nie warten. Aus: Jedes Dia wird berechnet, wenn es an der Reihe ist (weniger Speicher und Rechenarbeit).", "Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "„Diashow“ im Menü „Ansicht“ spielt die Fotos der Ansicht ab dem ausgewählten ab. Diese Einstellungen werden mit der Bibliothek gespeichert.", @@ -2833,7 +2828,7 @@ "\"LightCraft stopped unexpectedly\" and why, instead of \"could not open its window\", and the next start renders": "„LightCraft wurde unerwartet beendet“ und den Grund statt „konnte sein Fenster nicht öffnen“, und der nächste Start rendert", "on the CPU and explains how to turn the GPU back on.": "auf der CPU und erklärt, wie man die GPU wieder einschaltet.", "Resetting a slider": "Einen Regler zurücksetzen", - "Resting the pointer on a slider's name or its thumb now says that a double-click resets it (\"Exposure — double-click": "Ruht der Mauszeiger auf dem Namen oder dem Griff eines Reglers, wird jetzt angezeigt, dass ein Doppelklick ihn zurücksetzt („Belichtung — zum Zurücksetzen", + "Resting the pointer on a slider's name or its thumb now says that a double-click resets it (\"Exposure — double-click": "Ruht der Mauszeiger auf dem Namen oder dem Griff eines Reglers, wird jetzt angezeigt, dass ein Doppelklick ihn zurücksetzt („Belichtung – zum Zurücksetzen", "to reset it\"), in every language (issue #1436).": "doppelklicken“), in jeder Sprache (Issue #1436).", "A double-click on the thumb is a single step in History and Undo. Its first click no longer nudges the value to": "Ein Doppelklick auf den Griff ist ein einziger Schritt in Verlauf und Rückgängig. Sein erster Klick verschiebt den Wert nicht mehr dorthin,", "where the pointer landed, and a single click on the thumb leaves the value alone.": "wo der Zeiger landete, und ein einfacher Klick auf den Griff lässt den Wert unverändert.", @@ -2983,7 +2978,7 @@ "Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /": "Die Wahl eines Lightroom-Classic-Katalogordners unter Datei → Bibliothek öffnen… (oder eines seiner Ordner `Previews.lrdata` /", "`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The": "`.lrcat-data` oder der `.lrcat`) legt darin keine leere LightCraft-Bibliothek mehr an (Issue #891). Die", "library open now stays, and LightCraft says \"This is a Lightroom catalog, not a LightCraft library\" and offers": "geöffnete Bibliothek bleibt jetzt, und LightCraft meldet „Dies ist ein Lightroom-Katalog, keine LightCraft-Bibliothek“ und bietet", - "**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-Katalog importieren …** mit diesem Katalog an, oder Abbrechen. `lightcraft-cli --library` lehnt diese Ordner ebenfalls ab.", + "**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-Katalog importieren…** mit diesem Katalog an, oder Abbrechen. `lightcraft-cli --library` lehnt diese Ordner ebenfalls ab.", "Canon EOS R5 Mark II": "Canon EOS R5 Mark II", "Switching libraries": "Bibliotheken wechseln", "out. Clear Recent Libraries empties the list.": "ausgegraut. „Liste leeren“ leert die Liste.", @@ -3306,8 +3301,8 @@ "the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages": "dem Container, statt es zu dekodieren; kleine Vorschauen verwenden die eingebettete Miniatur der Datei. Die Release-Pakete", "(macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports": "(macOS, Windows, Linux, FreeBSD, Nix) werden jetzt wirklich mit HEIC-Unterstützung gebaut; ein Build ohne sie meldet", "\"HEIC/HEIF support isn't included in this build\" for each `.heic`/`.heif` at import.": "„HEIC/HEIF support isn't included in this build“ für jede `.heic`/`.heif`-Datei beim Import.", - "The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "Der Tonungsregler arbeitet in die richtige Richtung (Issues #188, #321): links mehr Grün, rechts mehr Magenta, wie seine Skala", - "shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint": "zeigt und wie in Lightroom, und Tonungswerte in Lightroom-XMP-Begleitdateien rendern jetzt wie dort. Eine benutzerdefinierte Tonung,", + "The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "Der Tönungsregler arbeitet in die richtige Richtung (Issues #188, #321): links mehr Grün, rechts mehr Magenta, wie seine Skala", + "shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint": "zeigt und wie in Lightroom, und Tönungswerte in Lightroom-XMP-Begleitdateien rendern jetzt wie dort. Eine benutzerdefinierte Tönung,", "saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).": "die in einer früheren Version gespeichert wurde, verschiebt jetzt in die andere Richtung; stelle sie neu ein (oder führe Auto / die Weißabgleichpipette erneut aus).", "White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the": "Der Weißabgleich bei DNGs (und anderen RAW-Dateien mit Farbmatrix) entwickelt das Foto für das neue Weiß über die", "camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey": "eigenen Farbmatrizen der Kamera neu, wie Lightroom es tut, statt die Farben des Renderings „Wie Aufnahme“ zu verschieben: Ein Grau,", @@ -3379,7 +3374,7 @@ "Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the": "RAW-Dateien der Sony A7R II (und anderer), deren Kamera-JPEG objektivkorrigiert ist, öffnen nicht mehr grau und zu dunkel (Issue #232): Der", "starting look is fitted to the camera JPEG away from edges when the misaligned edges spoil the fit on all pixels.": "Ausgangslook wird abseits der Ränder an das Kamera-JPEG angepasst, wenn die verschobenen Ränder die Anpassung über alle Pixel verderben.", "Lightroom Classic catalogs": "Lightroom-Classic-Kataloge", - "File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "Datei → Lightroom-Katalog importieren … öffnet `.lrcat` direkt, wobei die Originale an ihrem Ort referenziert werden.", + "File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "Datei → Lightroom-Katalog importieren… öffnet `.lrcat` direkt, wobei die Originale an ihrem Ort referenziert werden.", "Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;": "Bewertungen, Markierungen, Farbmarkierungen, Stichwörter, Sammlungen/Sätze, virtuelle Kopien und unterstützte Bearbeitungen werden übernommen;", "existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported": "vorhandene LightCraft-Bearbeitungen bleiben standardmäßig erhalten. Quelleinstellungen und -verlauf werden archiviert, nicht unterstützte", "fields are reported, and the original Lightroom database stays read-only. Rendering is approximate.": "Felder werden gemeldet, und die ursprüngliche Lightroom-Datenbank bleibt schreibgeschützt. Das Rendering ist eine Annäherung.", @@ -3423,5 +3418,24 @@ "favourites across restarts (issue #328).": "über Neustarts hinweg Favoriten (Issue #328).", "Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "Optionales entferntes SAM 3: Der native Editor bleibt lokal, und Objekt-, Beschreibungs- und Detail-", "inference on a Mac through SSH. Saved masks still render and export offline.": "Inferenz laufen über SSH auf einem Mac. Gespeicherte Masken werden weiterhin offline gerendert und exportiert.", - "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference)." + "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference).", + "Before/After Side by Side": "Vorher/Nachher nebeneinander", + "Clipping": "Beschneidung", + "Mask Overlay": "Maskenüberlagerung", + "All Mask Overlays": "Alle Maskenüberlagerungen", + "Mask Pins": "Maskenpins", + "Photo Counts": "Fotoanzahl", + "Focus Point": "Fokuspunkt", + "Layout Image": "Layoutbild", + "Detail View": "Detailansicht", + "Edit Panel": "Bedienfeld „Bearbeiten“", + "Close the open dialog, then drop the files again": "Schließe den geöffneten Dialog und lege die Dateien dann erneut ab", + "Close the open dialog to drop files here": "Schließe den geöffneten Dialog, um hier Dateien abzulegen", + "Drop to open the face model": "Ablegen, um das Gesichtsmodell zu öffnen", + "Drop to import": "Ablegen zum Importieren", + "Dropped Files": "Abgelegte Dateien", + "Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren", + "An import is running": "Ein Import läuft", + "An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist", + "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index fd1540169..33e5f52fc 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.", "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…", + "Inside: {names}": "Dentro: {names}", "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?", "There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?", "Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”", "Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?", "Listing folders — folders: {folders}, files: {files}": "Listando carpetas — carpetas: {folders}, archivos: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve.", + "Drop to review {n} item{} for import": "Suelta para revisar {n} elemento{} antes de importar", + "Drop to import {n} item{}": "Suelta para importar {n} elemento{}", + "Drop to import {n} preset{}": "Suelta para importar {n} ajuste{0} preestablecido{0}", + "Could not read {name}: {e}": "No se pudo leer {name}: {e}", + "Added {n} photo{}": "{n} foto{0} añadida{0}", + "Where should the Lightroom catalog “{catalog}” go?": "¿Dónde debe ir el catálogo de Lightroom “{catalog}”?", + "Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 46965c778..af22570ef 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -228,7 +228,6 @@ "Clear Filters": "Borrar filtros", "Clear Guides": "Borrar guías", "Clear History": "Borrar historial", - "Clear Preview Cache": "Vaciar caché de vistas previas", "Clear Quick Collection": "Borrar colección rápida", "Clear Reference": "Borrar referencia", "Clear all": "Borrar todo", @@ -279,7 +278,7 @@ "Company": "Empresa", "Compare": "Comparar", "Compare (Shift+C)": "Comparar (Mayús+C)", - "Compare Before and After": "Comparar antes y después", + "Before/After Side by Side": "Antes/Después lado a lado", "Compare needs at least two photos": "Comparar requiere al menos dos fotos", "Component options": "Opciones del componente", "Compression": "Compresión", @@ -292,6 +291,8 @@ "Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada", "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar", "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco", + "Dialogs": "Diálogos", + "Close a dialog by clicking outside it": "Cerrar un diálogo al hacer clic fuera de él", "Constrain Crop": "Restringir recorte", "Contact Sheet PDF": "PDF de hoja de contactos", "Continue": "Continuar", @@ -1413,15 +1414,15 @@ "Short Edge": "Lado corto", "Show": "Mostrar", "Show All": "Mostrar todo", - "Show Clipping": "Mostrar recorte de tonos", + "Clipping": "Recorte de tonos", "Show Deghost Overlay": "Mostrar superposición de eliminación de fantasmas", "Show Hidden Local Locations": "Mostrar ubicaciones ocultas de Local", "Show Luminance Map": "Mostrar mapa de luminancia", "Show Mask": "Mostrar máscara", - "Show Mask Overlay": "Mostrar superposición de máscara", - "Show All Mask Overlays": "Mostrar todas las superposiciones de máscara", + "Mask Overlay": "Superposición de máscara", + "All Mask Overlays": "Todas las superposiciones de máscara", "Show all overlays": "Mostrar todas las superposiciones", - "Show Mask Pins": "Mostrar chinchetas de máscara", + "Mask Pins": "Chinchetas de máscara", "Show Original": "Mostrar original", "Show Person": "Mostrar persona", "Slideshow, second window, All Metadata, System Info.": "Presentación, segunda ventana, Todos los metadatos, Información del sistema.", @@ -1430,7 +1431,7 @@ "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Los ARW de Sony de antes de 2017 aproximadamente (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II y sus", "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Los ARW sin pérdida reducidos de la Sony ILCE-7M4 ahora descodifican los mosaicos YCbCr submuestreados a RGB lineal,", "Sort Albums A–Z": "Ordenar álbumes A–Z", - "Show Photo Counts": "Mostrar recuento de fotos", + "Photo Counts": "Recuento de fotos", "Show Photos with Keyword": "Mostrar fotos con la palabra clave", "Show Pins": "Mostrar chinchetas", "Show Source": "Mostrar origen", @@ -2314,7 +2315,7 @@ "Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto → Eliminar foto", "LightCraft stopped": "LightCraft se detuvo", - "Show Focus Point": "Mostrar punto de enfoque", + "Focus Point": "Punto de enfoque", "Zoom to Focus Point": "Zoom al punto de enfoque", "Focus": "Enfoque", "Focus point": "Punto de enfoque", @@ -2342,5 +2343,17 @@ "Checking the GPU…": "Comprobando la GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.", - "Checking the GPU for faces": "Comprobando la GPU para las caras" + "Checking the GPU for faces": "Comprobando la GPU para las caras", + "Layout Image": "Imagen de diseño", + "Detail View": "Vista de detalle", + "Edit Panel": "Panel Editar", + "Close the open dialog, then drop the files again": "Cierra el cuadro de diálogo abierto y vuelve a soltar los archivos", + "Close the open dialog to drop files here": "Cierra el cuadro de diálogo abierto para soltar archivos aquí", + "Drop to open the face model": "Suelta para abrir el modelo de caras", + "Drop to import": "Suelta para importar", + "Dropped Files": "Archivos soltados", + "Import dropped files without review": "Importar los archivos soltados sin revisión", + "An import is running": "Hay una importación en curso", + "An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine", + "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos" } diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json index 698080e10..1daab3dab 100644 --- a/crates/ui-egui/locales/fi-formats.json +++ b/crates/ui-egui/locales/fi-formats.json @@ -102,6 +102,7 @@ "Imported {} photo{}": "Tuotuja kuvia: {}{:.0}", "In line for AI Denoise ({ahead} ahead)…": "Jonossa tekoälykohinanpoistoon ({ahead} edellä)…", "Insert at the cursor (also written {})": "Lisää kohdistimen kohtaan (kirjoitetaan myös {})", + "Inside: {names}": "Sisällä: {names}", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se poistetaan {count} kuvalta{:.0}, alla olevat avainsanat mukaan lukien. Kumoa palauttaa sen.", "Keywords of {n} selected photos": "Valittujen kuvien ({n}) avainsanat", "Label Set: {name}": "Merkintäjoukko: {name}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "”{to}” on jo henkilö. Yhdistäminen tekee kahdesta yhden henkilön; Kumoa erottaa ne uudelleen.", "• {assumption}": "• {assumption}", "… and {more} more": "… ja {more} lisää", - "…and {more} more": "…ja {more} lisää" + "…and {more} more": "…ja {more} lisää", + "Added {n} photo{}": "Lisättyjä kuvia: {n}{:.0}", + "Could not read {name}: {e}": "Tiedostoa {name} ei voitu lukea: {e}", + "Drop to import {n} item{}": "Pudota tuodaksesi (kohteita: {n}){:.0}", + "Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}", + "Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?", + "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})" } diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index ff1707ac7..6456a6624 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -306,7 +306,6 @@ "Clear Filters": "Tyhjennä suodattimet", "Clear Guides": "Tyhjennä apuviivat", "Clear History": "Tyhjennä historia", - "Clear Preview Cache": "Tyhjennä esikatselun välimuisti", "Clear Quick Collection": "Tyhjennä pikakokoelma", "Clear Recent Libraries": "Tyhjennä viimeisimmät kirjastot", "Clear Reference": "Tyhjennä vertailukuva", @@ -372,7 +371,6 @@ "Company": "Yritys", "Compare": "Vertaa", "Compare (Shift+C)": "Vertaa (Shift+C)", - "Compare Before and After": "Vertaa ennen ja jälkeen", "Compare needs at least two photos": "Vertailu vaatii vähintään kaksi kuvaa", "Component options": "Komponentin asetukset", "Compression": "Pakkaus", @@ -1822,19 +1820,13 @@ "Show": "Näytä", "Show / hide": "Näytä / piilota", "Show All": "Näytä kaikki", - "Show All Mask Overlays": "Näytä kaikki maskien peittokuvat", - "Show Clipping": "Näytä leikkautuminen", "Show Deghost Overlay": "Näytä haamukuvien poiston peittokuva", - "Show Focus Point": "Näytä tarkennuspiste", "Show Hidden Local Locations": "Näytä piilotetut paikalliset sijainnit", "Show Layout Image": "Näytä asettelukuva", "Show Luminance Map": "Näytä kirkkauskartta", "Show Mask": "Näytä maski", - "Show Mask Overlay": "Näytä maskin peittokuva", - "Show Mask Pins": "Näytä maskin nastat", "Show Original": "Näytä alkuperäinen", "Show Person": "Näytä henkilö", - "Show Photo Counts": "Näytä kuvamäärät", "Show Photos with Keyword": "Näytä kuvat, joilla on avainsana", "Show Pins": "Näytä nastat", "Show Sidebar Section": "Näytä sivupalkin osio", @@ -2284,10 +2276,12 @@ "or drop a .onnx file on the window": "tai pudota .onnx-tiedosto ikkunaan", "or drop photos and folders into this window": "tai pudota kuvia ja kansioita tähän ikkunaan", "or drop photos into this window": "tai pudota kuvia tähän ikkunaan", + "pick": "valittu kuva", "photo has its denoised picture.": "kuvalla on kohinanpoistettu kuvansa.", "photo waiting": "kuva odottaa", "photos have their denoised picture.": "kuvilla on kohinanpoistetut kuvansa.", "photos waiting": "kuvaa odottaa", + "reject": "hylätty", "saved in": "tallennettu kohteeseen", "second": "sekunti", "starts using it for AI Denoise": "alkaa käyttää sitä tekoälykohinanpoistoon", @@ -2311,5 +2305,24 @@ "− harder border · + softer border": "− terävämpi reuna · + pehmeämpi reuna", "≤ at most": "≤ enintään", "≥ at least": "≥ vähintään", - "★ Favorites": "★ Suosikit" + "★ Favorites": "★ Suosikit", + "All Mask Overlays": "Kaikki maskien peittokuvat", + "Before/After Side by Side": "Ennen/jälkeen vierekkäin", + "Clipping": "Leikkautuminen", + "Detail View": "Tarkastelunäkymä", + "Edit Panel": "Muokkauspaneeli", + "Focus Point": "Tarkennuspiste", + "Layout Image": "Asettelukuva", + "Mask Overlay": "Maskin peittokuva", + "Mask Pins": "Maskin nastat", + "Photo Counts": "Kuvamäärät", + "An import is running": "Tuonti on käynnissä", + "An import is running. Drop photos when it has finished": "Tuonti on käynnissä. Pudota kuvat, kun se on valmis", + "Close the open dialog to drop files here": "Sulje avoin valintaikkuna pudottaaksesi tiedostot tähän", + "Close the open dialog, then drop the files again": "Sulje avoin valintaikkuna ja pudota tiedostot sitten uudelleen", + "Drop to import": "Pudota tuodaksesi", + "Drop to open the face model": "Pudota avataksesi kasvomallin", + "Dropped Files": "Pudotetut tiedostot", + "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä", + "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index ae2b77872..294273781 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -51,7 +51,7 @@ "Example: {example}": "Exemple : {example}", "Exported {n} photo{}": "{n} photo{} exportée(s)", "Exported {ok} of {total} photo{}": "{ok} photo{} exportée(s) sur {total}", - "Exported {} point curve preset{}": "{} préréglage{} de courbe de points exporté(s)", + "Exported {} point curve preset{}": "{} préréglage{} de courbe à points exporté(s)", "Exported {} preset{}": "{} préréglage{} exporté(s)", "Exporting {} of {total}": "Exportation : {} sur {total}", "Filter bar — {active} active filter{}": "Barre de filtres — {active} filtre{} actif(s)", @@ -62,7 +62,7 @@ "Import cancelled · {} photo{} added": "Importation annulée · {} photo{} ajoutée(s)", "Import failed: {e}": "Échec de l’importation : {e}", "Import warnings ({n}):\n{warnings}": "Avertissements d’importation ({n}) :\n{warnings}", - "Imported {n} point curve preset{}": "{n} préréglage{} de courbe de points importé(s)", + "Imported {n} point curve preset{}": "{n} préréglage{} de courbe à points importé(s)", "Imported {n} preset{}": "{n} préréglage{} importé(s)", "Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)", "Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)", @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "Supprimer le mot-clé « {name} » ?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Il est retiré de {count} photo{}, avec les mots-clés qu’il contient. Annuler le rétablit.", "Create Keyword Tag Inside “{name}”…": "Créer un mot-clé dans « {name} »…", + "Inside: {names}": "À l’intérieur : {names}", "“{parent}” has a “{name}” already. Merge the two?": "« {parent} » contient déjà « {name} ». Fusionner les deux ?", "There is a “{name}” at the top level already. Merge the two?": "Il existe déjà « {name} » au premier niveau. Fusionner les deux ?", "Added “{keyword}” to {n} photo{}": "« {keyword} » ajouté à {n} photo{}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »", "Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?", "Listing folders — folders: {folders}, files: {files}": "Liste des dossiers — dossiers : {folders}, fichiers : {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{0} déplacée{0} vers « {1} ». Modifier → Annuler les remet en place.", + "Drop to review {n} item{} for import": "Déposez pour vérifier {n} élément{} avant l'import", + "Drop to import {n} item{}": "Déposez pour importer {n} élément{}", + "Drop to import {n} preset{}": "Déposez pour importer {n} préréglage{}", + "Could not read {name}: {e}": "Impossible de lire {name} : {e}", + "Added {n} photo{}": "{n} photo{} ajoutée(s)", + "Where should the Lightroom catalog “{catalog}” go?": "Où importer le catalogue Lightroom « {catalog} » ?", + "Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 2f22a70c1..33a892ecf 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -26,7 +26,7 @@ "Add Folder to Local": "Ajouter le dossier à Local", "Add Mask": "Ajouter un masque", "Add Object Click": "Ajouter un clic sur l'objet", - "Add Point Color Sample": "Ajouter un échantillon de couleur ponctuelle", + "Add Point Color Sample": "Ajouter un échantillon de point de couleur", "Add Red Eye Correction": "Ajouter une correction des yeux rouges", "Add Remove Spot": "Ajouter une suppression de tache", "Add Selected Photos": "Ajouter les photos sélectionnées", @@ -76,21 +76,21 @@ "Apply Color Label Set": "Appliquer un jeu d'étiquettes de couleur", "Apply Filter Preset": "Appliquer un préréglage de filtre", "Apply Metadata Preset": "Appliquer un préréglage de métadonnées", - "Apply Point Curve Preset": "Appliquer un préréglage de courbe de points", + "Apply Point Curve Preset": "Appliquer un préréglage de courbe à points", "Apply Preset": "Appliquer un préréglage", "Apply Settings JSON": "Appliquer des réglages JSON", "Approved": "Approuvée", - "Aqua": "Aigue-marine", - "Aqua Hue": "Teinte aigue-marine", - "Aqua Luminance": "Luminance aigue-marine", - "Aqua Saturation": "Saturation aigue-marine", + "Aqua": "Turquoise", + "Aqua Hue": "Teinte turquoise", + "Aqua Luminance": "Luminance turquoise", + "Aqua Saturation": "Saturation turquoise", "ArtCraft Website": "Site web d'ArtCraft", "ArtCraft — more creative apps": "ArtCraft : d'autres applications créatives", "As Shot": "Tel quel", "As shot": "Tel quel", "Ascending": "Croissant", "Aspect": "Format", - "Aspect Ratio": "Rapport d'aspect", + "Aspect Ratio": "Ratio de format", "Assisted Culling": "Tri assisté", "Assisted Culling…": "Tri assisté…", "Auto": "Auto", @@ -142,7 +142,7 @@ "Best of Similar Shots": "Meilleure des prises similaires", "Binary assets added": "Ressources binaires ajoutées", "Binary assets removed": "Ressources binaires supprimées", - "Bit depth": "Profondeur de bits", + "Bit depth": "Profondeur de couleur", "Black & White": "Noir et blanc", "Blacks": "Noirs", "Bleach Bypass": "Bleach bypass", @@ -214,7 +214,6 @@ "Clear Filters": "Effacer les filtres", "Clear Guides": "Effacer les repères", "Clear History": "Effacer l'historique", - "Clear Preview Cache": "Vider le cache des aperçus", "Clear Quick Collection": "Vider la collection rapide", "Clear Reference": "Effacer la référence", "Clear all": "Tout effacer", @@ -264,7 +263,7 @@ "Company": "Société", "Compare": "Comparer", "Compare (Shift+C)": "Comparer (Maj+C)", - "Compare Before and After": "Comparer avant et après", + "Before/After Side by Side": "Avant/Après côte à côte", "Compare needs at least two photos": "Comparer nécessite au moins deux photos", "Component options": "Options du composant", "Compression": "Compression", @@ -374,9 +373,9 @@ "Delete Permanently": "Supprimer définitivement", "Delete Photo": "Supprimer la photo", "Delete Photos": "Supprimer les photos", - "Delete Point Color": "Supprimer la couleur ponctuelle", - "Delete Point Color Sample": "Supprimer l'échantillon de couleur ponctuelle", - "Delete Point Curve Preset": "Supprimer le préréglage de courbe de points", + "Delete Point Color": "Supprimer le point de couleur", + "Delete Point Color Sample": "Supprimer l'échantillon de point de couleur", + "Delete Point Curve Preset": "Supprimer le préréglage de courbe à points", "Delete Preset": "Supprimer le préréglage", "Delete Red Eye": "Supprimer les yeux rouges", "Delete Red Eye Correction": "Supprimer la correction des yeux rouges", @@ -470,8 +469,8 @@ "Export As…": "Exporter sous…", "Export Group…": "Exporter le groupe…", "Export Now": "Exporter maintenant", - "Export Point Curve Presets": "Exporter les préréglages de courbe de points", - "Export Point Curve Presets…": "Exporter les préréglages de courbe de points…", + "Export Point Curve Presets": "Exporter les préréglages de courbe à points", + "Export Point Curve Presets…": "Exporter les préréglages de courbe à points…", "Export Presets": "Exporter les préréglages", "Export Presets…": "Exporter les préréglages…", "Export User Presets…": "Exporter les préréglages utilisateur…", @@ -602,12 +601,12 @@ "Grid Info": "Informations de la grille", "Gritty Street": "Rue brute", "Group": "Groupe", - "Group by Date: Automatic": "Regrouper par date : automatique", - "Group by Day": "Regrouper par jour", - "Group by Month": "Regrouper par mois", - "Group by Year": "Regrouper par année", - "Group by date": "Regrouper par date", - "Group into Stack": "Regrouper en pile", + "Group by Date: Automatic": "Grouper par date : automatique", + "Group by Day": "Grouper par jour", + "Group by Month": "Grouper par mois", + "Group by Year": "Grouper par année", + "Group by date": "Grouper par date", + "Group into Stack": "Grouper en pile", "Group name": "Nom du groupe", "Guided": "Guidé", "Guided Upright": "Upright guidé", @@ -664,8 +663,8 @@ "Import Lightroom Catalog…": "Importer un catalogue Lightroom…", "Import Photos": "Importer des photos", "Import Photos…": "Importer des photos…", - "Import Point Curve Presets": "Importer des préréglages de courbe de points", - "Import Point Curve Presets…": "Importer des préréglages de courbe de points…", + "Import Point Curve Presets": "Importer des préréglages de courbe à points", + "Import Point Curve Presets…": "Importer des préréglages de courbe à points…", "Import Presets": "Importer des préréglages", "Import Presets…": "Importer des préréglages…", "Import Profiles": "Importer des profils", @@ -1037,10 +1036,10 @@ "Picks": "Sélectionnées", "Pictures": "Images", "Pixels": "Pixels", - "Point Color": "Couleur ponctuelle", - "Point Curve": "Courbe de points", - "Point Curve Presets": "Préréglages de courbe de points", - "Point curve": "Courbe de points", + "Point Color": "Point de couleur", + "Point Curve": "Courbe à points", + "Point Curve Presets": "Préréglages de courbe à points", + "Point curve": "Courbe à points", "Portrait": "Portrait", "Position": "Position", "Preferences": "Préférences", @@ -1257,8 +1256,8 @@ "Save Metadata Preset": "Enregistrer le préréglage de métadonnées", "Save Metadata Preset…": "Enregistrer le préréglage de métadonnées…", "Save Metadata to File": "Enregistrer les métadonnées dans le fichier", - "Save Point Curve Preset": "Enregistrer le préréglage de courbe de points", - "Save Point Curve…": "Enregistrer la courbe de points…", + "Save Point Curve Preset": "Enregistrer le préréglage de courbe à points", + "Save Point Curve…": "Enregistrer la courbe à points…", "Save as Preset": "Enregistrer comme préréglage", "Save as Set…": "Enregistrer comme jeu…", "Save as Smart Album…": "Enregistrer comme album intelligent…", @@ -1308,7 +1307,7 @@ "Set Image Zoom and Pan": "Définir le zoom et le déplacement de l'image", "Set Keyboard Shortcut": "Définir le raccourci clavier", "Set Mask Adjustments": "Définir les réglages du masque", - "Set Point Curve": "Définir la courbe de points", + "Set Point Curve": "Définir la courbe à points", "Set Profile": "Définir le profil", "Set Rating": "Définir la note", "Set Top of Stack": "Définir le haut de la pile", @@ -1343,17 +1342,17 @@ "Short Edge": "Petit côté", "Show": "Afficher", "Show All": "Tout afficher", - "Show Clipping": "Afficher l'écrêtage", + "Clipping": "Écrêtage", "Show Deghost Overlay": "Afficher l'incrustation de suppression des images fantômes", "Show Hidden Local Locations": "Afficher les emplacements Local masqués", "Show Luminance Map": "Afficher la carte de luminance", "Show Mask": "Afficher le masque", - "Show Mask Overlay": "Afficher l'incrustation du masque", - "Show All Mask Overlays": "Afficher toutes les superpositions de masque", + "Mask Overlay": "Incrustation du masque", + "All Mask Overlays": "Toutes les incrustations de masque", "Show all overlays": "Afficher toutes les superpositions", - "Show Mask Pins": "Afficher les épingles de masque", + "Mask Pins": "Épingles de masque", "Show Original": "Afficher l'original", - "Show Photo Counts": "Afficher le nombre de photos", + "Photo Counts": "Nombre de photos", "Show Photos with Keyword": "Afficher les photos avec le mot-clé", "Show Pins": "Afficher les épingles", "Show Source": "Afficher la source", @@ -1458,7 +1457,7 @@ "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Réglage ciblé : faites glisser vers le haut/bas sur la photo pour ajuster la courbe à cet endroit", "Teal & Amber": "Bleu-vert et ambre", "Teal & Orange": "Bleu-vert et orange", - "Temp": "Temp.", + "Temp": "Température", "Temperature": "Température", "Template": "Modèle", "Temporary session — your library isn't open, and nothing you do here is saved.": "Session temporaire : votre bibliothèque n'est pas ouverte et rien de ce que vous faites ici n'est enregistré.", @@ -1930,7 +1929,7 @@ "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", "Looking for changes": "Recherche des modifications", - "Arrange Panel": "Disposer le panneau", + "Arrange Panel": "Modifier le panneau", "Close panel": "Fermer le panneau", "Float panel": "Panneau flottant", "Move to group": "Déplacer vers le groupe", @@ -1956,7 +1955,7 @@ "Perceptual": "Perceptif", "Relative": "Relatif", "Simulate Paper & Ink": "Simuler le papier et l'encre", - "Content-Aware": "Contenu pris en compte", + "Content-Aware": "Selon le contenu", "Sidebar Sections": "Sections de la barre latérale", "Reset Sidebar Sections": "Réinitialiser les sections de la barre latérale", "Hide This Section": "Masquer cette section", @@ -2249,7 +2248,7 @@ "Intro screen": "Écran d'introduction", "Ending screen": "Écran de fin", "Shows": "Affiche", - "Colour only": "Couleur seule", + "Colour only": "Couleur unie", "Override colour": "Remplacer la couleur", "Own duration (off: the slide duration)": "Durée propre (désactivée : celle des diapositives)", "Duration": "Durée", @@ -2312,7 +2311,7 @@ "Reset all edits (Cmd+Shift+R)": "Réinitialiser toutes les retouches (Cmd+Maj+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo → Supprimer la photo", "LightCraft stopped": "LightCraft s’est arrêté", - "Show Focus Point": "Afficher le point de mise au point", + "Focus Point": "Point de mise au point", "Zoom to Focus Point": "Zoomer sur le point de mise au point", "Focus": "Mise au point", "Focus point": "Point de mise au point", @@ -2340,5 +2339,53 @@ "Checking the GPU…": "Vérification du GPU…", "CPU": "Processeur", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.", - "Checking the GPU for faces": "Vérification du GPU pour les visages" + "Checking the GPU for faces": "Vérification du GPU pour les visages", + "Layout Image": "Image de mise en page", + "Detail View": "Vue de détail", + "Edit Panel": "Panneau Modifier", + "Close the open dialog, then drop the files again": "Fermez la boîte de dialogue ouverte, puis déposez à nouveau les fichiers", + "Close the open dialog to drop files here": "Fermez la boîte de dialogue ouverte pour déposer des fichiers ici", + "Drop to open the face model": "Déposez pour ouvrir le modèle de visages", + "Drop to import": "Déposez pour importer", + "Dropped Files": "Fichiers déposés", + "Import dropped files without review": "Importer les fichiers déposés sans vérification", + "An import is running": "Une importation est en cours", + "An import is running. Drop photos when it has finished": "Une importation est en cours. Déposez les photos quand elle sera terminée", + "Finish Lightroom catalog import before adding photos": "Terminez l'importation du catalogue Lightroom avant d'ajouter des photos", + "The library is empty: the catalog's photos, collections and edits become its first.": "La bibliothèque est vide : les photos, collections et retouches du catalogue seront les premières.", + "The catalog's photos, collections and edits join the ones already in this library.": "Les photos, collections et retouches du catalogue rejoignent celles déjà présentes dans cette bibliothèque.", + "Create a new library": "Créer une nouvelle bibliothèque", + "LightCraft opens the new library, then imports the catalog into it. Its folder must be new or empty.": "LightCraft ouvre la nouvelle bibliothèque, puis y importe le catalogue. Son dossier doit être nouveau ou vide.", + "Don't ask again (always merge into the open library)": "Ne plus demander (toujours fusionner avec la bibliothèque ouverte)", + "To be asked again: Settings → General → When importing a Lightroom catalog.": "Pour que la question revienne : Réglages → Général → À l’importation d’un catalogue Lightroom.", + "Create Library and Import": "Créer la bibliothèque et importer", + "Another library was opened since this window appeared: nothing was imported": "Une autre bibliothèque a été ouverte depuis l’apparition de cette fenêtre : rien n’a été importé", + "When importing a Lightroom catalog": "À l’importation d’un catalogue Lightroom", + "Ask where to import it": "Demander où l’importer", + "Always merge into the open library": "Toujours fusionner avec la bibliothèque ouverte", + "The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library.": "L’importation lit le catalogue sans le modifier. Ensuite, choisissez si ses photos, retouches, mots-clés et collections rejoignent la bibliothèque ouverte ou une nouvelle bibliothèque.", + "At launch": "Au lancement", + "Open the last library": "Ouvrir la dernière bibliothèque", + "Ask me": "Me demander", + "Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.": "« Me demander » affiche vos bibliothèques récentes au lancement. Maintenir Alt (Option sur Mac) pendant que la fenêtre apparaît le demande une fois.", + "Open a Library": "Ouvrir une bibliothèque", + "Choose the library to open.": "Choisissez la bibliothèque à ouvrir.", + "No recent libraries. Open one from a folder, or make a new one.": "Aucune bibliothèque récente. Ouvrez-en une depuis un dossier, ou créez-en une nouvelle.", + "Remove from List": "Retirer de la liste", + "The library itself stays where it is.": "La bibliothèque elle-même reste où elle est.", + "Open": "Ouvrir", + "Open Another…": "Ouvrir une autre…", + "Open now": "Ouverte en ce moment", + "Last opened": "Ouverte le", + "Not found: moved, renamed or on a disconnected drive": "Introuvable : déplacée, renommée ou sur un disque déconnecté", + "There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft", + "This is a file, not a library folder": "C’est un fichier, pas un dossier de bibliothèque", + "This folder can't be read:": "Ce dossier ne peut pas être lu :", + "The library's catalog can't be read:": "Le catalogue de la bibliothèque ne peut pas être lu :", + "Not answering yet: the drive may be asleep or offline": "Pas encore de réponse : le disque est peut-être en veille ou hors ligne", + "This library couldn't be checked": "Impossible de vérifier cette bibliothèque", + "Checking recent libraries": "Vérification des bibliothèques récentes", + "The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte", + "Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages", + "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :" } diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json index b3887f917..7fc325522 100644 --- a/crates/ui-egui/locales/id-formats.json +++ b/crates/ui-egui/locales/id-formats.json @@ -297,5 +297,13 @@ "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}: resolusi penuhnya ({full}) memerlukan memori melebihi kapasitas komputer ini", "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}, resolusi maksimum untuk panorama (resolusi penuhnya {full})", "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.", - "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}" + "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}", + "Added {n} photo{}": "{n} foto ditambahkan{:.0}", + "Could not read {name}: {e}": "Tidak dapat membaca {name}: {e}", + "Drop to import {n} item{}": "Jatuhkan untuk mengimpor {n} item{:.0}", + "Drop to import {n} preset{}": "Jatuhkan untuk mengimpor {n} preset{:.0}", + "Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}", + "Inside: {names}": "Di dalam: {names}", + "Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?", + "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})" } diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index aefe04417..abb6a732e 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -308,7 +308,6 @@ "Clear Filters": "Hapus filter", "Clear Guides": "Hapus garis panduan", "Clear History": "Hapus riwayat", - "Clear Preview Cache": "Kosongkan cache pratinjau", "Clear Print Layout": "Kosongkan tata letak cetak", "Clear Quick Collection": "Kosongkan koleksi cepat", "Clear Recent Libraries": "Hapus daftar pustaka terbaru", @@ -372,7 +371,6 @@ "Company": "Perusahaan", "Compare": "Bandingkan", "Compare (Shift+C)": "Bandingkan (Shift+C)", - "Compare Before and After": "Bandingkan Sebelum dan Sesudah", "Compare needs at least two photos": "Perlu minimal dua foto untuk dibandingkan", "Component options": "Opsi komponen", "Compression": "Kompresi", @@ -1817,19 +1815,13 @@ "Short Edge": "Sisi pendek", "Show": "Tampilkan", "Show All": "Tampilkan semua", - "Show All Mask Overlays": "Tampilkan semua overlay masker", - "Show Clipping": "Tampilkan clipping", "Show Deghost Overlay": "Tampilkan overlay deghost", - "Show Focus Point": "Tampilkan titik fokus", "Show Hidden Local Locations": "Tampilkan lokasi Lokal yang disembunyikan", "Show Layout Image": "Tampilkan gambar tata letak", "Show Luminance Map": "Tampilkan peta luminans", "Show Mask": "Tampilkan masker", - "Show Mask Overlay": "Tampilkan overlay masker", - "Show Mask Pins": "Tampilkan pin masker", "Show Original": "Tampilkan yang asli", "Show Person": "Tampilkan orang", - "Show Photo Counts": "Tampilkan jumlah foto", "Show Photos with Keyword": "Tampilkan foto dengan kata kunci ini", "Show Pins": "Tampilkan pin", "Show Sidebar Section": "Tampilkan bagian bilah samping", @@ -2325,5 +2317,24 @@ "≤ at most": "≤ maksimal", "≥ at least": "≥ minimal", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "All Mask Overlays": "Semua overlay masker", + "Before/After Side by Side": "Sebelum/Sesudah berdampingan", + "Clipping": "Clipping", + "Detail View": "Tampilan Detail", + "Edit Panel": "Panel Edit", + "Focus Point": "Titik fokus", + "Layout Image": "Gambar tata letak", + "Mask Overlay": "Overlay masker", + "Mask Pins": "Pin masker", + "Photo Counts": "Jumlah foto", + "An import is running": "Impor sedang berjalan", + "An import is running. Drop photos when it has finished": "Impor sedang berjalan. Jatuhkan foto setelah selesai", + "Close the open dialog to drop files here": "Tutup dialog yang terbuka untuk menjatuhkan file di sini", + "Close the open dialog, then drop the files again": "Tutup dialog yang terbuka, lalu jatuhkan file lagi", + "Drop to import": "Jatuhkan untuk mengimpor", + "Drop to open the face model": "Jatuhkan untuk membuka model wajah", + "Dropped Files": "File yang dijatuhkan", + "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto", + "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan" } diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index ccd01e6a7..7efae90e9 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -283,6 +283,7 @@ "Delete the effect preset “{name}”?": "Eliminare il preset effetto “{name}”?", "{n} raws can now be developed from their raw data": "Raw che ora si possono sviluppare dai loro dati raw: {n}", "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "Inside: {names}": "Al suo interno: {names}", "Template preview: {name}": "Anteprima del modello: {name}", "{n} photos in view · {slides} slides · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}", "Applied {name}": "{name} applicato", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}", "Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}", "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro.", - "Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}" + "Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}", + "Drop to review {n} item{} for import": "Rilascia per rivedere prima dell'importazione (elementi: {n}){:.0}", + "Drop to import {n} item{}": "Rilascia per importare (elementi: {n}){:.0}", + "Drop to import {n} preset{}": "Rilascia per importare predefiniti: {n}{:.0}", + "Could not read {name}: {e}": "Impossibile leggere {name}: {e}", + "Added {n} photo{}": "Foto aggiunte: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Dove importare il catalogo Lightroom “{catalog}”?", + "Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})" } diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 759fe53d0..a75448fbb 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -215,7 +215,6 @@ "Clear Filters": "Cancella filtri", "Clear Guides": "Cancella guide", "Clear History": "Cancella cronologia", - "Clear Preview Cache": "Svuota cache delle anteprime", "Clear Quick Collection": "Svuota la raccolta rapida", "Clear Reference": "Cancella riferimento", "Clear all": "Cancella tutto", @@ -266,7 +265,6 @@ "Company": "Azienda", "Compare": "Confronta", "Compare (Shift+C)": "Confronta (Maiusc+C)", - "Compare Before and After": "Confronta prima e dopo", "Compare needs at least two photos": "Per confrontare servono almeno due foto", "Component options": "Opzioni del componente", "Compression": "Compressione", @@ -1229,7 +1227,6 @@ "Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Gli album smart si aggiornano da soli: cambia le loro regole, oppure usa Foto → Elimina foto", "LightCraft stopped": "LightCraft si è chiuso", - "Show Focus Point": "Mostra punto di messa a fuoco", "Zoom to Focus Point": "Zoom sul punto di messa a fuoco", "Focus": "Messa a fuoco", "Focus point": "Punto di messa a fuoco", @@ -1381,17 +1378,12 @@ "Short Edge": "Lato corto", "Show": "Mostra", "Show All": "Mostra tutto", - "Show Clipping": "Mostra tagli", "Show Deghost Overlay": "Mostra sovrapposizione della rimozione fantasmi", "Show Hidden Local Locations": "Mostra le posizioni locali nascoste", "Show Luminance Map": "Mostra mappa di luminanza", "Show Mask": "Mostra maschera", - "Show Mask Overlay": "Mostra sovrapposizione maschera", - "Show All Mask Overlays": "Mostra tutte le sovrapposizioni maschera", "Show all overlays": "Mostra tutte le sovrapposizioni", - "Show Mask Pins": "Mostra i punti delle maschere", "Show Original": "Mostra originale", - "Show Photo Counts": "Mostra il numero di foto", "Show Photos with Keyword": "Mostra le foto con la parola chiave", "Show Pins": "Mostra i punti", "Show Source": "Mostra origine", @@ -2345,5 +2337,24 @@ "Loupe Info 2": "Info lente 2", "Use Defaults": "Usa predefiniti", "Camera & Lens": "Fotocamera e obiettivo", - "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2." + "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.", + "Before/After Side by Side": "Prima/Dopo affiancati", + "Clipping": "Tagli tonali", + "Mask Overlay": "Sovrapposizione maschera", + "Mask Pins": "Punti delle maschere", + "Photo Counts": "Numero di foto", + "Focus Point": "Punto di messa a fuoco", + "All Mask Overlays": "Tutte le sovrapposizioni maschera", + "Layout Image": "Immagine di layout", + "Detail View": "Vista dettaglio", + "Edit Panel": "Pannello Modifica", + "Close the open dialog, then drop the files again": "Chiudi la finestra di dialogo aperta, poi rilascia di nuovo i file", + "Close the open dialog to drop files here": "Chiudi la finestra di dialogo aperta per rilasciare qui i file", + "Drop to open the face model": "Rilascia per aprire il modello dei volti", + "Drop to import": "Rilascia per importare", + "Dropped Files": "File rilasciati", + "Import dropped files without review": "Importa i file rilasciati senza revisione", + "An import is running": "Un'importazione è in corso", + "An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita", + "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ce56500b5..99c14bf16 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -227,6 +227,7 @@ "Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}", "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…", + "Inside: {names}": "この中: {names}", "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?", "There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?", "Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません", "Listing folders — folders: {folders}, files: {files}": "フォルダーを一覧中 — フォルダー:{folders}、ファイル:{files}", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})", + "Drop to review {n} item{} for import": "ドロップして {n} 件を確認してから読み込み{:.0}", + "Drop to import {n} item{}": "ドロップして {n} 件を読み込み{:.0}", + "Drop to import {n} preset{}": "ドロップして {n} 件のプリセットを読み込み{:.0}", + "Could not read {name}: {e}": "{name} を読み取れませんでした: {e}", + "Added {n} photo{}": "{n}枚を追加しました{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Lightroom カタログ「{catalog}」をどこに読み込みますか?", + "Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4c55ee494..c3f11e6e5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -215,7 +215,6 @@ "Clear Filters": "フィルターを解除", "Clear Guides": "ガイドを消去", "Clear History": "履歴を消去", - "Clear Preview Cache": "プレビューキャッシュを消去", "Clear Quick Collection": "クイックコレクションを空にする", "Clear Reference": "参照写真を解除", "Clear all": "すべて解除", @@ -260,7 +259,7 @@ "Company": "企業", "Compare": "比較", "Compare (Shift+C)": "比較(⇧C)", - "Compare Before and After": "編集前と編集後を比較", + "Before/After Side by Side": "編集前/編集後の左右表示", "Compare needs at least two photos": "比較には2枚以上の写真が必要です", "Component options": "構成要素の設定", "Compression": "圧縮", @@ -1289,17 +1288,17 @@ "Short Edge": "短辺", "Show": "表示", "Show All": "すべて表示", - "Show Clipping": "白飛び・黒つぶれを表示", + "Clipping": "白飛び・黒つぶれ", "Show Deghost Overlay": "ゴースト除去範囲を表示", "Show Hidden Local Locations": "非表示にしたローカルの場所を表示", "Show Luminance Map": "輝度マップを表示", "Show Mask": "マスクを表示", - "Show Mask Overlay": "マスクの重ね表示", - "Show All Mask Overlays": "すべてのマスクオーバーレイを表示", + "Mask Overlay": "マスクの重ね表示", + "All Mask Overlays": "すべてのマスクの重ね表示", "Show all overlays": "すべてのオーバーレイを表示", - "Show Mask Pins": "マスクのピンを表示", + "Mask Pins": "マスクのピン", "Show Original": "元の写真を表示", - "Show Photo Counts": "写真の枚数を表示", + "Photo Counts": "写真の枚数", "Show Photos with Keyword": "このキーワードの写真を表示", "Show Pins": "ピンを表示", "Show Source": "コピー元を表示", @@ -2314,7 +2313,7 @@ "Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 → 写真を削除を使ってください", "LightCraft stopped": "LightCraftが終了しました", - "Show Focus Point": "フォーカスポイントを表示", + "Focus Point": "フォーカスポイント", "Zoom to Focus Point": "フォーカスポイントにズーム", "Focus": "フォーカス", "Focus point": "フォーカスポイント", @@ -2342,5 +2341,17 @@ "Checking the GPU…": "GPU を確認中…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。", - "Checking the GPU for faces": "顔用の GPU を確認中" + "Checking the GPU for faces": "顔用の GPU を確認中", + "Layout Image": "レイアウト画像", + "Detail View": "詳細表示", + "Edit Panel": "編集パネル", + "Close the open dialog, then drop the files again": "開いているダイアログを閉じてから、もう一度ファイルをドロップしてください", + "Close the open dialog to drop files here": "ファイルをここにドロップするには、開いているダイアログを閉じてください", + "Drop to open the face model": "ドロップして顔モデルを開く", + "Drop to import": "ドロップして読み込み", + "Dropped Files": "ドロップしたファイル", + "Import dropped files without review": "ドロップしたファイルを確認せずに読み込む", + "An import is running": "読み込み中です", + "An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください", + "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください" } diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index c3b48ef62..460368261 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -268,6 +268,7 @@ "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "Inside: {names}": "შიგნით: {names}", "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", "Saved template {name}": "Saved template {name}", "Skipped {problem}": "Skipped {problem}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", "Listing folders — folders: {folders}, files: {files}": "საქაღალდეების ჩამოთვლა — საქაღალდეები: {folders}, ფაილები: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.", + "Drop to review {n} item{} for import": "ჩააგდეთ იმპორტამდე გადასახედად (ელემენტები: {n}){:.0}", + "Drop to import {n} item{}": "ჩააგდეთ იმპორტისთვის (ელემენტები: {n}){:.0}", + "Drop to import {n} preset{}": "ჩააგდეთ პრესეტების იმპორტისთვის: {n}{:.0}", + "Could not read {name}: {e}": "ვერ წაიკითხა {name}: {e}", + "Added {n} photo{}": "დაემატა ფოტო: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "სად გადავიტანოთ Lightroom-ის კატალოგი „{catalog}“?", + "Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}" } diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 066f2ecd2..13fd48d3c 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -373,7 +373,6 @@ "Clear Filters": "ფილტრების გასუფთავება", "Clear Guides": "დამხმარე ხაზების გასუფთავება", "Clear History": "ისტორიის გასუფთავება", - "Clear Preview Cache": "წინასწარი ხედების ქეშის გასუფთავება", "Clear Quick Collection": "სწრაფი კოლექციის გასუფთავება", "Clear Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება", "Clear Reference": "ეტალონის გასუფთავება", @@ -429,7 +428,7 @@ "Company": "კომპანია", "Compare": "შედარება", "Compare (Shift+C)": "შედარება (Shift+C)", - "Compare Before and After": "მანამდე და შემდეგ შედარება", + "Before/After Side by Side": "მანამდე/შემდეგ გვერდიგვერდ", "Compare and Reference View panes say \"Rendering…\" (or that the photo can't be opened) the same way, so a": "შედარებისა და ეტალონური ხედის პანელები ასევე წერს „რენდერდება…“ (ან რომ ფოტოს გახსნა შეუძლებელია), ასე რომ", "Compare needs at least two photos": "შედარებას სულ მცირე ორი ფოტო სჭირდება", "Component options": "კომპონენტის პარამეტრები", @@ -1902,17 +1901,17 @@ "Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and": "მალსახმობები რედაქტირებადია: დახმარება → კლავიატურის მალსახმობები (⌘/) ყველა ბრძანებას საძიებო ველით ჩამოთვლის; დაწკაპეთ მალსახმობზე და", "Show": "ჩვენება", "Show All": "ყველას ჩვენება", - "Show All Mask Overlays": "ნიღბების ყველა ზედდების ჩვენება", - "Show Clipping": "ჩამოჭრილი ტონების ჩვენება", + "All Mask Overlays": "ნიღბების ყველა ზედდება", + "Clipping": "ჩამოჭრილი ტონები", "Show Deghost Overlay": "აჩრდილების მოცილების ზედდების ჩვენება", "Show Hidden Local Locations": "დამალული ლოკალური მდებარეობების ჩვენება", "Show Luminance Map": "სიკაშკაშის რუკის ჩვენება", "Show Mask": "ნიღბის ჩვენება", - "Show Mask Overlay": "ნიღბის ზედდების ჩვენება", - "Show Mask Pins": "ნიღბის მარკერების ჩვენება", + "Mask Overlay": "ნიღბის ზედდება", + "Mask Pins": "ნიღბის მარკერები", "Show Original": "ორიგინალის ჩვენება", "Show Person": "ადამიანის ჩვენება", - "Show Photo Counts": "ფოტოების რაოდენობის ჩვენება", + "Photo Counts": "ფოტოების რაოდენობა", "Show Photos with Keyword": "საკვანძო სიტყვის მქონე ფოტოების ჩვენება", "Show Pins": "მარკერების ჩვენება", "Show Sidebar Section": "გვერდითი ზოლის სექციის ჩვენება", @@ -2719,5 +2718,18 @@ "≥ at least": "≥ არანაკლებ", "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "Focus Point": "ფოკუსის წერტილი", + "Layout Image": "განლაგების სურათი", + "Detail View": "დეტალური ხედი", + "Edit Panel": "რედაქტირების პანელი", + "Close the open dialog, then drop the files again": "დახურეთ გახსნილი დიალოგი და ფაილები ხელახლა ჩააგდეთ", + "Close the open dialog to drop files here": "ფაილების აქ ჩასაგდებად დახურეთ გახსნილი დიალოგი", + "Drop to open the face model": "ჩააგდეთ სახეების მოდელის გასახსნელად", + "Drop to import": "ჩააგდეთ იმპორტისთვის", + "Dropped Files": "ჩაგდებული ფაილები", + "Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე", + "An import is running": "იმპორტი მიმდინარეობს", + "An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება", + "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი" } diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index e3ce316a0..20c7f840d 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -5,17 +5,17 @@ " · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}", " · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)", " · {} duplicate{} skipped": " · pominięte duplikaty: {}{:.0}", - " · {} failed: {}": " · {} nieudanych: {}", - " · {} moved": " · {} przeniesionych", + " · {} failed: {}": " · nieudane ({}): {}", + " · {} moved": " · przeniesione: {}", " · {} not readable": " · {} nie można odczytać", " · {} original{} left at the source": " · oryginały pozostawione w źródle: {}{:.0}", - " · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych", + " · {} restored from Recently Deleted": " · przywrócone z Ostatnio usuniętych: {}", " — not carried over: {}{}": " — nie przeniesiono: {}{}", "+{} more": "+{} więcej", ", {failed} file{} not readable": ", nieczytelne pliki: {failed}{:.0}", "; {kept} already had a location": "; {kept} miało już przypisaną lokalizację", "; {outside} outside its time range": "; {outside} poza zakresem dat", - "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, zatwierdzenia: {}, wiersze: +{} / −{} (Δ {}), zasoby binarne: +{} / −{}, {} – {}", "Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć", "Add {} Photo{}": "Dodaj zdjęcia: {}{:.0}", "Add {} Photo{}, restore {}": "Dodaj zdjęcia: {}{:.0}, przywróć: {}", @@ -29,12 +29,12 @@ "Already in the library: {} photo{} selected in All Photos": "Już w bibliotece — zaznaczone zdjęcia we Wszystkich zdjęciach: {}{:.0}", "Auto ({})": "Automatycznie ({})", "Auto Import: added {} photo{}": "Automatyczny import — dodane zdjęcia: {}{:.0}", - "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć", + "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany obejmują zdjęcia ({n})", "Automatic ({} MB)": "Automatycznie ({} MB)", "Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})", "Build Smart Previews ({scope})": "Utwórz podglądy inteligentne ({scope})", "Build Standard-Sized Previews ({scope})": "Utwórz podglądy o standardowym rozmiarze ({scope})", - "Building previews for {} photos…": "Tworzenie podglądów dla {} zdjęć…", + "Building previews for {} photos…": "Tworzenie podglądów (zdjęcia: {})…", "Click {}:1": "Kliknij {}:1", "Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}", "Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych", @@ -42,7 +42,7 @@ "Decoder: {reason}": "Dekoder: {reason}", "Delete \"{label}\"": "Usuń „{label}”", "Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)", - "Delete {n} Photos": "Usuń {n} zdjęć", + "Delete {n} Photos": "Usuń zdjęcia ({n})", "Delete “{current}”": "Usuń „{current}”", "Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})", "Done ({what}): {done} photo{plural}": "Gotowe ({what}) — zdjęcia: {done}{plural:.0}", @@ -69,10 +69,10 @@ "Label Set: {name}": "Zestaw etykiet: {name}", "LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}", "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nie mógł otworzyć okna: {e}\n\nSzczegóły znajdują się w pliku dziennika: {path}", - "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom: {photos} zdjęć, {collections} kolekcji, {missing} brakujących", - "Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony: dodano {imported} zdjęć", - "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony: dodano {imported} zdjęć, błędów: {failed}", - "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony: dodano {imported} zdjęć; ostrzeżenie indeksu: {warning}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom — zdjęcia: {photos}, kolekcje: {collections}, brakujące: {missing}", + "Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony — dodane zdjęcia: {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony — dodane zdjęcia: {imported}, błędy: {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony — dodane zdjęcia: {imported}; ostrzeżenie indeksu: {warning}", "Lightroom import failed: {e}{warning}": "Import z Lightroom nieudany: {e}{warning}", "Lightroom inspection failed: {e}{warning}": "Sprawdzanie katalogu Lightroom nieudane: {e}{warning}", "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT lub Apache-2.0. Czcionki: {} (OFL). Ikony: oryginalne.", @@ -89,7 +89,7 @@ "Profile: {}": "Profil: {}", "Proof Copy ({})": "Kopia próbna ({})", "Proof Preview · {}": "Podgląd próbny · {}", - "Quick Develop · {n} photos": "Szybka obróbka · {n} zdjęć", + "Quick Develop · {n} photos": "Szybka obróbka · zdjęcia: {n}", "Quick Develop: {name}": "Szybka obróbka: {name}", "Rated {}": "Ocena: {}", "Reading Lightroom catalog… {done} of {total}": "Odczytywanie katalogu Lightroom… {done} z {total}", @@ -100,7 +100,7 @@ "Remove “{}” from Favorites": "Usuń „{}” z ulubionych", "Removed from {}": "Usunięto z {}", "Removed {what} from {name}": "Usunięto {what} z {name}", - "Rename {n} Photos…": "Zmień nazwy {n} zdjęć…", + "Rename {n} Photos…": "Zmień nazwy zdjęć ({n})…", "Rename “{name}”": "Zmień nazwę „{name}”", "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na zdjęciach ({n}){:.0} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.", "Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}", @@ -122,7 +122,7 @@ "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.", "Version: {}": "Wersja: {}", "Virtual copy “{name}” of {of}": "Kopia wirtualna „{name}” zdjęcia {of}", - "Working on {what} for {} photos…": "Przetwarzanie {what} dla {} zdjęć…", + "Working on {what} for {} photos…": "Przetwarzanie: {what} (zdjęcia: {})…", "none (CPU rendering): {why}": "brak (renderowanie na CPU): {why}", "the {n} selected photos": "{n} zaznaczonych zdjęć", "the {} photos in view": "{} zdjęć w bieżącym widoku", @@ -132,12 +132,12 @@ "{label} Grading": "Gradacja: {label}", "{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu", "{label} {index} of {n}": "{label} {index} z {n}", - "{label}, {n} photos": "{label}, {n} zdjęć", + "{label}, {n} photos": "{label}, zdjęcia: {n}", "{matching} of {t} photos": "{matching} z {t} zdjęć", - "{matching} photos": "{matching} pasujących zdjęć", + "{matching} photos": "Pasujące zdjęcia: {matching}", "{mode} spot {} of {}": "{mode}: plamka {} z {}", - "{month}": "{month}", - "{month} {year}": "{month} {year}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}.{year}", "{msg} ({failed} failed)": "{msg} (nieudanych: {failed})", "{name} Label": "Etykieta {name}", "{name} Off": "{name}: wyłączone", @@ -148,8 +148,8 @@ "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){0:.0}: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu teraz oznacza ich utratę.{1:.0}", "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){:.0}: {e} — LightCraft ponowi próbę", "{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}", - "{n} photos": "{n} zdjęć", - "{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry", + "{n} photos": "zdjęcia ({n})", + "{n} photos in {} are hidden by the filters above": "Zdjęcia w {} ukryte przez powyższe filtry: {n}", "{n} photo{}": "Zdjęcia: {n}{:.0}", "{n} photo{} match · updates automatically as photos change": "Pasujące zdjęcia: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć", "{n} photo{} now · updates automatically as photos change": "Zdjęcia teraz: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć", @@ -158,25 +158,25 @@ "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} (zdjęcia: {n}){:.0}", "{weekday}, {day}": "{weekday}, {day}", - "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}", + "{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}", "{what} merge added": "Dodano po scaleniu: {what}", "{what} merge failed: {e}": "Scalanie nieudane: {what}: {e}", "{year}": "{year}", "{{date:…}} directives: {}": "Dyrektywy {{date:…}}: {}", "{} and higher": "{} i nowsze", - "{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń", - "{} failed": "{} nieudanych", - "{} files": "{} plików", + "{} contributors · {} commits": "Współtwórcy: {} · zatwierdzenia: {}", + "{} failed": "{}: niepowodzenie", + "{} files": "Pliki: {}", "{} files and {f} folder{}": "Pliki: {}, foldery: {f}{:.0}", "{} item{}": "Elementy: {}{:.0}", "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.", - "{} photos": "{} zdjęć", - "{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV", - "{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°", + "{} photos": "Zdjęcia: {}", + "{} photos · exposures {} EV": "Zdjęcia: {} · ekspozycje {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "Zdjęcia: {} · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}", "{} sample{}": "Próbki: {}{:.0}", "{} sec": "{} s", - "{} spot(s) on this photo": "{} plamek na tym zdjęciu", + "{} spot(s) on this photo": "Plamki na tym zdjęciu: {}", "{} — show their photos": "{} — pokaż ich zdjęcia", "· {dups} already in the library": "· {dups} już w bibliotece", "· {n} photo{}": "· zdjęcia: {n}{:.0}", @@ -184,7 +184,7 @@ "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.", "Filtered: {label}": "Filtrowanie: {label}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Podobne zdjęcia: {n}{:.0} · Widok → Wyczyść filtry, aby zobaczyć wszystkie", - "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć", + "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązane zdjęcia: {}", "{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)", ", {} star{}": ", gwiazdki: {}{:.0}", ", {} label": ", {} etykieta", @@ -199,7 +199,7 @@ "{} MB": "{} MB", "Making the denoised picture… {}%": "Tworzenie odszumionego obrazu… {}%", "{name} · {terms}": "{name} · {terms}", - "{n} {noun}": "{n} {noun}", + "{n} {noun}": "{noun}: {n}", "Trained on: {provenance}": "Wytrenowano na: {provenance}", " · {queued} waiting": " · {queued} oczekuje", "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Wyświetla warunki modelu, następnie pobiera {} z {host}, instaluje go i zaczyna używać. Żadne inne dane nie są wysyłane.", @@ -252,50 +252,58 @@ " · kept, not applied: {}": " · zachowane, niezastosowane: {}", " — kept but not applied: {}": " — zachowane, ale niezastosowane: {}", "Apply to “{mask}” on Selected Photos": "Zastosuj do „{mask}” na zaznaczonych zdjęciach", - "Delete the effect preset “{name}”?": "Usunąć ustawienie wstępne efektu „{name}”?", - "Delete these {n} effect presets?": "Usunąć te ustawienia wstępne efektów ({n})?", - "Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia wstępne efektów ({n})?{:.0} Tej operacji nie można cofnąć.", + "Delete the effect preset “{name}”?": "Usunąć ustawienie efektu „{name}”?", + "Delete these {n} effect presets?": "Usunąć zaznaczone ustawienia efektów ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia efektów ({n})?{:.0} Tej operacji nie można cofnąć.", "Effect: {name}": "Efekt: {name}", - "Exported {} effect preset{}": "Wyeksportowane ustawienia wstępne efektów: {}{:.0}", + "Exported {} effect preset{}": "Wyeksportowane ustawienia efektów: {}{:.0}", "Imported {e} mask effect{} (Masking ▸ Effect)": "Zaimportowane efekty masek: {e}{:.0} (Maskowanie → Efekt)", - "Update “{name}” with Current Settings": "Aktualizuj „{name}” bieżącymi ustawieniami", - "{n} preset{}": "Ustawienia wstępne: {n}{:.0}", + "Update “{name}” with Current Settings": "Zaktualizuj „{name}” bieżącymi ustawieniami", + "{n} preset{}": "Ustawienia: {n}{:.0}", "{count} more warnings.": "Pozostałe ostrzeżenia: {count}.", "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Nie zaimportowano obrazów z katalogu: {skipped}. Ich rekordy w katalogu są niekompletne.", "{photos} catalog records processed; {collections} collections.": "Przetworzone rekordy katalogu: {photos}; kolekcje: {collections}.", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia „Znajdź brakujące zdjęcia”, aby ponownie je połączyć.", - "Applied {name}": "Zastosowano: {name}", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia Znajdź brakujące zdjęcia, aby je ponownie powiązać.", + "Applied {name}": "Zastosowano {name}", "Delete “{name}”?": "Usunąć „{name}”?", - "Updated {name}": "Zaktualizowano: {name}", + "Updated {name}": "Zaktualizowano {name}", "Exported to {path}": "Wyeksportowano do {path}", "Saved template {name}": "Zapisano szablon {name}", - "Imported {name}": "Zaimportowano: {name}", + "Imported {name}": "Zaimportowano {name}", "Skipped {problem}": "Pominięto: {problem}", "…and {more} more": "…i jeszcze {more}", "Template preview: {name}": "Podgląd szablonu: {name}", "{n} photos in view · {slides} slides · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}", "{n} pages": "Strony: {n}", "{n} JPEG files": "Pliki JPEG: {n}", - "{s} s of AV1 video": "{s} s wideo AV1", + "{s} s of AV1 video": "Wideo AV1: {s} s", "Slideshow exported: {what}": "Wyeksportowano pokaz slajdów: {what}", " · {n} skipped (could not be rendered)": " · pominięte (nie udało się wyrenderować): {n}", - "{n} raws can now be developed from their raw data": "Pliki RAW, które można teraz wywołać z danych surowych: {n}", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików pomocniczych XMP zdjęć ({n}). Czy ich folder jest offline? LightCraft ponawia próby.", + "{n} raws can now be developed from their raw data": "Pliki RAW, które można już wywołać z danych surowych: {n}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików XMP dla zdjęć ({n}) — czy ich folder jest niedostępny? LightCraft ponawia próby.", "Create the keyword “{name}”": "Utwórz słowo kluczowe „{name}”", - "new, inside {parent}": "nowe, wewnątrz {parent}", + "new, inside {parent}": "nowe, w {parent}", "Keywords of {n} selected photos": "Słowa kluczowe zaznaczonych zdjęć ({n})", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” z {n} zdjęć. Edytuj → Cofnij przywraca je.", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” ze zdjęć ({n}). Edytuj → Cofnij przywraca.", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft nieoczekiwanie zakończył działanie: {e}\n\nSzczegóły są w pliku dziennika: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft nieoczekiwanie zakończył działanie: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (liczba twarzy: {n}).{:.0} Pliki nie są zmieniane.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij ponownie je rozdzieli.", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano połączenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano połączenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar to {full})", + "Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (twarze: {n}).{:.0} Pliki nie są zmieniane.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij rozdzieli je z powrotem.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano scalenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano scalenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar: {full})", "Color space: Rec. 2020 {curve} (HDR AVIF)": "Przestrzeń kolorów: Rec. 2020 {curve} (HDR AVIF)", "{n} selected photos · {pages} pages": "Zaznaczone zdjęcia: {n} · strony: {pages}", "Already in “{}”": "Już w „{}”", "Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?", "Listing folders — folders: {folders}, files: {files}": "Przeglądanie folderów — foldery: {folders}, pliki: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je.", + "Drop to review {n} item{} for import": "Upuść, aby przejrzeć przed importem (elementy: {n}){:.0}", + "Drop to import {n} item{}": "Upuść, aby zaimportować (elementy: {n}){:.0}", + "Drop to import {n} preset{}": "Upuść, aby zaimportować ustawienia wstępne: {n}{:.0}", + "Could not read {name}: {e}": "Nie można odczytać {name}: {e}", + "Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}", + "Inside: {names}": "Wewnątrz: {names}", + "Where should the Lightroom catalog “{catalog}” go?": "Dokąd zaimportować katalog Lightroom „{catalog}”?", + "Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}" } diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index aba88ef44..f03a81762 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -210,7 +210,6 @@ "Clear Filters": "Wyczyść filtry", "Clear Guides": "Wyczyść prowadnice", "Clear History": "Wyczyść historię", - "Clear Preview Cache": "Wyczyść pamięć podręczną podglądów", "Clear Quick Collection": "Wyczyść szybką kolekcję", "Clear Reference": "Wyczyść zdjęcie referencyjne", "Clear all": "Wyczyść wszystko", @@ -260,7 +259,6 @@ "Company": "Firma", "Compare": "Porównaj", "Compare (Shift+C)": "Porównaj (Shift+C)", - "Compare Before and After": "Porównaj przed i po", "Compare needs at least two photos": "Porównanie wymaga co najmniej dwóch zdjęć", "Component options": "Opcje składnika", "Compression": "Kompresja", @@ -1319,15 +1317,11 @@ "Short Edge": "Krótsza krawędź", "Show": "Pokaż", "Show All": "Pokaż wszystko", - "Show Clipping": "Pokaż prześwietlenia i niedoświetlenia", "Show Deghost Overlay": "Pokaż nakładkę usuwania duchów", "Show Hidden Local Locations": "Pokaż ukryte lokalizacje lokalne", "Show Luminance Map": "Pokaż mapę luminancji", "Show Mask": "Pokaż maskę", - "Show Mask Overlay": "Pokaż nakładkę maski", - "Show Mask Pins": "Pokaż znaczniki masek", "Show Original": "Pokaż oryginał", - "Show Photo Counts": "Pokaż liczbę zdjęć", "Show Photos with Keyword": "Pokaż zdjęcia ze słowem kluczowym", "Show Pins": "Pokaż znaczniki", "Show Source": "Pokaż źródło", @@ -1711,17 +1705,17 @@ "Use": "Użyj", "Waiting to start…": "Oczekiwanie na uruchomienie…", "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Działa na plikach RAW z aparatów z typową matrycą Bayera. Pozostałe pliki zachowują zwykłe odszumianie.", - "cached picture": "obraz w pamięci podręcznej", - "cached pictures": "obrazy w pamięci podręcznej", + "cached picture": "Obrazy w pamięci podręcznej", + "cached pictures": "Obrazy w pamięci podręcznej", "commercial use allowed": "dozwolony użytek komercyjny", "in use": "w użyciu", "installed": "zainstalowano", "makes AI Denoise available": "włącza odszumianie AI", "non-commercial use only": "wyłącznie do użytku niekomercyjnego", - "photo has its denoised picture.": "zdjęcie ma już odszumioną wersję.", - "photo waiting": "zdjęcie oczekuje", - "photos have their denoised picture.": "zdjęcia mają już odszumione wersje.", - "photos waiting": "zdjęć oczekuje", + "photo has its denoised picture.": "Zdjęcia z odszumioną wersją", + "photo waiting": "Oczekujące zdjęcia", + "photos have their denoised picture.": "Zdjęcia z odszumioną wersją", + "photos waiting": "Oczekujące zdjęcia", "starts using it for AI Denoise": "rozpoczyna używanie modelu do odszumiania AI", "terms unclear": "niejasne warunki", "the graphics card": "karta graficzna", @@ -2138,7 +2132,6 @@ "Remove from favorites": "Usuń z ulubionych", "Reorder Album": "Zmień kolejność w albumie", "Reset Sidebar Sections": "Przywróć sekcje paska bocznego", - "Show All Mask Overlays": "Pokaż wszystkie nakładki masek", "Show Person": "Pokaż osobę", "Show Sidebar Section": "Pokaż sekcję paska bocznego", "Show all overlays": "Pokaż wszystkie nakładki", @@ -2236,7 +2229,6 @@ "Background Tasks": "Zadania w tle", "Cancel Background Task": "Anuluj zadanie w tle", "Select Only Active Photo": "Zaznacz tylko aktywne zdjęcie", - "Show Focus Point": "Pokaż punkt ostrości", "Zoom to Focus Point": "Powiększ do punktu ostrości", "Show in Compare": "Pokaż w porównaniu", "Increase Grid Thumbnail Size": "Zwiększ rozmiar miniatur w siatce", @@ -2269,5 +2261,24 @@ "SDR Shadows": "Cienie SDR", "SDR Whites": "Biele SDR", "SDR Clarity": "Przejrzystość SDR", - "Face Models on the GPU": "Modele twarzy przetwarzane na GPU" + "Face Models on the GPU": "Modele twarzy przetwarzane na GPU", + "Before/After Side by Side": "Przed/Po obok siebie", + "Clipping": "Prześwietlenia i niedoświetlenia", + "Mask Overlay": "Nakładka maski", + "Mask Pins": "Znaczniki masek", + "Photo Counts": "Liczba zdjęć", + "All Mask Overlays": "Wszystkie nakładki masek", + "Focus Point": "Punkt ostrości", + "Layout Image": "Obraz układu", + "Detail View": "Widok szczegółów", + "Edit Panel": "Panel Edytuj", + "Close the open dialog, then drop the files again": "Zamknij otwarte okno dialogowe, a potem upuść pliki ponownie", + "Close the open dialog to drop files here": "Zamknij otwarte okno dialogowe, aby upuścić tu pliki", + "Drop to open the face model": "Upuść, aby otworzyć model twarzy", + "Drop to import": "Upuść, aby zaimportować", + "Dropped Files": "Upuszczone pliki", + "Import dropped files without review": "Importuj upuszczone pliki bez przeglądu", + "An import is running": "Import jest w toku", + "An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy", + "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index bab81a368..f17f2c0fd 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -227,6 +227,7 @@ "Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.", "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…", + "Inside: {names}": "Dentro: {names}", "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?", "There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?", "Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem", "Listing folders — folders: {folders}, files: {files}": "Listando pastas — pastas: {folders}, arquivos: {files}", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})", + "Drop to review {n} item{} for import": "Solte para revisar antes de importar (itens: {n}){:.0}", + "Drop to import {n} item{}": "Solte para importar (itens: {n}){:.0}", + "Drop to import {n} preset{}": "Solte para importar predefinições: {n}{:.0}", + "Could not read {name}: {e}": "Não foi possível ler {name}: {e}", + "Added {n} photo{}": "{n} foto{0} adicionada{0}", + "Where should the Lightroom catalog “{catalog}” go?": "Para onde vai o catálogo do Lightroom “{catalog}”?", + "Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 5b4ae4409..88e955d26 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -221,7 +221,6 @@ "Clear Filters": "Limpar filtros", "Clear Guides": "Limpar guias", "Clear History": "Limpar histórico", - "Clear Preview Cache": "Limpar cache de prévias", "Clear Quick Collection": "Limpar coleção rápida", "Clear Reference": "Limpar referência", "Clear all": "Limpar tudo", @@ -272,7 +271,7 @@ "Company": "Empresa", "Compare": "Comparar", "Compare (Shift+C)": "Comparar (Shift+C)", - "Compare Before and After": "Comparar antes e depois", + "Before/After Side by Side": "Antes/Depois lado a lado", "Compare needs at least two photos": "A comparação requer pelo menos duas fotos", "Component options": "Opções do componente", "Compression": "Compressão", @@ -1366,17 +1365,17 @@ "Short Edge": "Aresta curta", "Show": "Mostrar", "Show All": "Mostrar tudo", - "Show Clipping": "Mostrar recorte de tons", + "Clipping": "Recorte de tons", "Show Deghost Overlay": "Mostrar sobreposição de remoção de fantasma", "Show Hidden Local Locations": "Mostrar locais ocultos de Local", "Show Luminance Map": "Mostrar mapa de luminância", "Show Mask": "Mostrar máscara", - "Show Mask Overlay": "Mostrar sobreposição da máscara", - "Show All Mask Overlays": "Mostrar todas as sobreposições de máscara", + "Mask Overlay": "Sobreposição da máscara", + "All Mask Overlays": "Todas as sobreposições de máscara", "Show all overlays": "Mostrar todas as sobreposições", - "Show Mask Pins": "Mostrar pinos da máscara", + "Mask Pins": "Pinos da máscara", "Show Original": "Mostrar original", - "Show Photo Counts": "Mostrar contagem de fotos", + "Photo Counts": "Contagem de fotos", "Show Photos with Keyword": "Mostrar fotos com a palavra-chave", "Show Pins": "Mostrar pinos", "Show Source": "Mostrar origem", @@ -2154,7 +2153,7 @@ "Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Álbuns inteligentes se atualizam sozinhos: altere as regras ou use Foto → Excluir foto", "LightCraft stopped": "O LightCraft parou", - "Show Focus Point": "Mostrar ponto de foco", + "Focus Point": "Ponto de foco", "Zoom to Focus Point": "Zoom no ponto de foco", "Focus": "Foco", "Focus point": "Ponto de foco", @@ -2188,5 +2187,17 @@ "Color Overlay on B&W": "Sobreposição de cor em P&B", "Image on Black": "Imagem sobre preto", "Image on White": "Imagem sobre branco", - "White on Black": "Branco sobre preto" + "White on Black": "Branco sobre preto", + "Layout Image": "Imagem de layout", + "Detail View": "Exibição de detalhe", + "Edit Panel": "Painel Editar", + "Close the open dialog, then drop the files again": "Feche a caixa de diálogo aberta e solte os arquivos novamente", + "Close the open dialog to drop files here": "Feche a caixa de diálogo aberta para soltar arquivos aqui", + "Drop to open the face model": "Solte para abrir o modelo de rostos", + "Drop to import": "Solte para importar", + "Dropped Files": "Arquivos soltos", + "Import dropped files without review": "Importar arquivos soltos sem revisão", + "An import is running": "Uma importação está em andamento", + "An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar", + "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 1efc30268..76b7af427 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.", "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…", + "Inside: {names}": "Внутри: {names}", "“{parent}” has a “{name}” already. Merge the two?": "В «{parent}» уже есть «{name}». Объединить их?", "There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?", "Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?", "Listing folders — folders: {folders}, files: {files}": "Просмотр папок — папок: {folders}, файлов: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их.", + "Drop to review {n} item{} for import": "Отпустите для проверки перед импортом (элементов: {n}){:.0}", + "Drop to import {n} item{}": "Отпустите для импорта (элементов: {n}){:.0}", + "Drop to import {n} preset{}": "Отпустите для импорта пресетов: {n}{:.0}", + "Could not read {name}: {e}": "Не удалось прочитать {name}: {e}", + "Added {n} photo{}": "Добавлено фото: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Куда импортировать каталог Lightroom «{catalog}»?", + "Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf191bb51..5c8816c5e 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -181,7 +181,6 @@ "Clear Filters": "Сбросить фильтры", "Clear Guides": "Убрать направляющие", "Clear History": "Очистить историю", - "Clear Preview Cache": "Очистить кэш просмотров", "Clear Quick Collection": "Очистить быструю коллекцию", "Clear Reference": "Убрать эталон", "Clear all": "Очистить всё", @@ -223,7 +222,7 @@ "Comments panel": "Панель комментариев", "Compare": "Сравнение", "Compare (Shift+C)": "Сравнение (Shift+C)", - "Compare Before and After": "Сравнить «до» и «после»", + "Before/After Side by Side": "До/после рядом", "Compare needs at least two photos": "Для сравнения нужно минимум два фото", "Component options": "Параметры компонента", "Compression": "Сжатие", @@ -1134,17 +1133,17 @@ "Sharpness": "Резкость", "Short Edge": "Короткая сторона", "Show All": "Показать все", - "Show Clipping": "Показать отсечение", + "Clipping": "Отсечение", "Show Deghost Overlay": "Показать наложение удаления артефактов", "Show Hidden Local Locations": "Показать скрытые места в «Локальном»", "Show Luminance Map": "Показать карту яркости", "Show Mask": "Показать маску", - "Show Mask Overlay": "Показать наложение маски", - "Show All Mask Overlays": "Показать все наложения масок", + "Mask Overlay": "Наложение маски", + "All Mask Overlays": "Все наложения масок", "Show all overlays": "Показать все наложения", - "Show Mask Pins": "Показать булавки масок", + "Mask Pins": "Булавки масок", "Show Original": "Показать оригинал", - "Show Photo Counts": "Показывать количество фото", + "Photo Counts": "Количество фото", "Show Photos with Keyword": "Показать фото с ключевым словом", "Show Pins": "Показать булавки", "Show Source": "Показать источник", @@ -2307,7 +2306,7 @@ "Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Смарт-альбомы обновляются сами: измените их правила или выберите Фото → Удалить фото", "LightCraft stopped": "LightCraft остановился", - "Show Focus Point": "Показать точку фокусировки", + "Focus Point": "Точка фокусировки", "Zoom to Focus Point": "Увеличить по точке фокусировки", "Focus": "Фокус", "Focus point": "Точка фокусировки", @@ -2335,5 +2334,17 @@ "Checking the GPU…": "Проверка ГП…", "CPU": "ЦП", "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.", - "Checking the GPU for faces": "Проверка ГП для лиц" + "Checking the GPU for faces": "Проверка ГП для лиц", + "Layout Image": "Изображение макета", + "Detail View": "Режим детализации", + "Edit Panel": "Панель «Правка»", + "Close the open dialog, then drop the files again": "Закройте открытое окно и перетащите файлы ещё раз", + "Close the open dialog to drop files here": "Закройте открытое окно, чтобы перетащить сюда файлы", + "Drop to open the face model": "Отпустите, чтобы открыть модель лиц", + "Drop to import": "Отпустите для импорта", + "Dropped Files": "Перетащенные файлы", + "Import dropped files without review": "Импортировать перетащенные файлы без проверки", + "An import is running": "Импорт уже выполняется", + "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится", + "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото" } diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json index 50e3fa4be..9a71a0288 100644 --- a/crates/ui-egui/locales/sv-formats.json +++ b/crates/ui-egui/locales/sv-formats.json @@ -297,5 +297,13 @@ "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.", "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.", - "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}" + "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}", + "Added {n} photo{}": "{n} foto tillagda{:.0}", + "Could not read {name}: {e}": "Kunde inte läsa {name}: {e}", + "Drop to import {n} item{}": "Släpp för att importera {n} objekt{:.0}", + "Drop to import {n} preset{}": "Släpp för att importera {n} förinställningar{:.0}", + "Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}", + "Inside: {names}": "Inuti: {names}", + "Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?", + "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})" } diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index 76f08c2d3..d9af4b8e8 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -350,7 +350,6 @@ "Clear form": "Rensa formulär", "Clear Guides": "Rensa stödlinjer", "Clear History": "Rensa historiken", - "Clear Preview Cache": "Rensa förhandsgranskningscache", "Clear Quick Collection": "Rensa snabbsamling", "Clear rating": "Rensa betyg", "Clear rating and advance": "Rensa betyg och gå vidare", @@ -416,7 +415,6 @@ "Company": "Företag", "Compare": "Jämför", "Compare (Shift+C)": "Jämför (Shift+C)", - "Compare Before and After": "Jämför före och efter", "Compare needs at least two photos": "Jämförelse kräver minst två foton", "Component options": "Komponentalternativ", "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimerade Nikon-NEF (förlustfritt och förstörande komprimerade, 12 och 14 bitar – t.ex. D3200, D5100, D7000, D750, D850, Z 50)", @@ -1975,9 +1973,7 @@ "Show": "Visa", "Show / hide": "Visa/dölj", "Show All": "Visa alla", - "Show All Mask Overlays": "Visa alla masköverlägg", "Show all overlays": "Visa alla överlägg", - "Show Clipping": "Visa klippning", "Show commands without a shortcut": "Visa kommandon utan kortkommando", "Show Deghost Overlay": "Visa överlägg för spökborttagning", "Show destination gamut warning": "Visa färgomfångsvarning för målet", @@ -1996,13 +1992,10 @@ "Show Luminance Map": "Visa luminanskarta", "Show Mask": "Visa mask", "Show mask": "Visa mask", - "Show Mask Overlay": "Visa masköverlägg", - "Show Mask Pins": "Visa masknålar", "Show on Map": "Visa på kartan", "Show Original": "Visa original", "Show overlay (O)": "Visa överlägg (O)", "Show Person": "Visa person", - "Show Photo Counts": "Visa antal foton", "Show photos": "Visa foton", "Show Photos with Keyword": "Visa foton med nyckelordet", "Show Pins": "Visa nålar", @@ -2414,5 +2407,24 @@ "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-dra för att räta upp, ⇧G guidad upprätning, ett rutnät under transformering.", "★ Favorites": "★ Favoriter", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "All Mask Overlays": "Alla masköverlägg", + "Before/After Side by Side": "Före/efter sida vid sida", + "Clipping": "Klippning", + "Detail View": "Detaljvy", + "Edit Panel": "Redigeringspanel", + "Focus Point": "Fokuspunkt", + "Layout Image": "Layoutbild", + "Mask Overlay": "Masköverlägg", + "Mask Pins": "Masknålar", + "Photo Counts": "Antal foton", + "An import is running": "En import pågår", + "An import is running. Drop photos when it has finished": "En import pågår. Släpp fotona när den är klar", + "Close the open dialog to drop files here": "Stäng den öppna dialogrutan för att släppa filer här", + "Close the open dialog, then drop the files again": "Stäng den öppna dialogrutan och släpp sedan filerna igen", + "Drop to import": "Släpp för att importera", + "Drop to open the face model": "Släpp för att öppna ansiktsmodellen", + "Dropped Files": "Släppta filer", + "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton", + "Import dropped files without review": "Importera släppta filer utan granskning" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 0ada44e08..36022ac1a 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "Видалити ключове слово «{name}»?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Його буде знято з фото (кількість: {count}{:.0}) разом із вкладеними ключовими словами. Скасування поверне його.", "Create Keyword Tag Inside “{name}”…": "Створити ключове слово в «{name}»…", + "Inside: {names}": "Усередині: {names}", "“{parent}” has a “{name}” already. Merge the two?": "«{parent}» уже містить «{name}». Об’єднати їх?", "There is a “{name}” at the top level already. Merge the two?": "На верхньому рівні вже є «{name}». Об’єднати їх?", "Added “{keyword}” to {n} photo{}": "«{keyword}» додано до фото: {n}{:.0}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?", "Listing folders — folders: {folders}, files: {files}": "Перегляд тек — тек: {folders}, файлів: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх.", + "Drop to review {n} item{} for import": "Відпустіть для перегляду перед імпортом (елементів: {n}){:.0}", + "Drop to import {n} item{}": "Відпустіть для імпорту (елементів: {n}){:.0}", + "Drop to import {n} preset{}": "Відпустіть для імпорту наборів налаштувань: {n}{:.0}", + "Could not read {name}: {e}": "Не вдалося прочитати {name}: {e}", + "Added {n} photo{}": "Додано фото: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Куди імпортувати каталог Lightroom «{catalog}»?", + "Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 4dff39f78..39090c0b3 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -533,7 +533,6 @@ "Clean Studio": "Чиста студія", "Clear Cache": "Очистити кеш", "Clear Filters": "Скинути фільтри", - "Clear Preview Cache": "Очистити кеш переглядів", "Clear Quick Collection": "Очистити швидку колекцію", "Clear Reference": "Очистити еталон", "Clear all": "Очистити все", @@ -562,7 +561,7 @@ "Command Journal": "Журнал команд", "Compare": "Порівняти", "Compare (Shift+C)": "Порівняти (Shift+C)", - "Compare Before and After": "Порівняти до й після", + "Before/After Side by Side": "До/після поруч", "Compare needs at least two photos": "Для порівняння потрібно щонайменше два фото", "Component options": "Параметри компонента", "Concrete Cool": "Холодний бетон", @@ -1360,16 +1359,16 @@ "Shadows split": "Розділення тіней", "Shift by": "Зсунути на", "Short Edge": "Короткий край", - "Show Clipping": "Показати відсікання", + "Clipping": "Відсікання", "Show Deghost Overlay": "Показати накладання усунення двоїння", "Show Hidden Local Locations": "Показати приховані локальні розташування", "Show Luminance Map": "Показати карту яскравості", - "Show Mask Overlay": "Показати накладання маски", - "Show All Mask Overlays": "Показати всі накладення масок", + "Mask Overlay": "Накладання маски", + "All Mask Overlays": "Усі накладання масок", "Show all overlays": "Показати всі накладення", - "Show Mask Pins": "Показати маркери масок", + "Mask Pins": "Маркери масок", "Show Original": "Показати оригінал", - "Show Photo Counts": "Показати кількість фото", + "Photo Counts": "Кількість фото", "Show Photos with Keyword": "Показати фото з ключовим словом", "Show Pins": "Показати маркери", "Show Source": "Показати джерело", @@ -2312,7 +2311,7 @@ "Reset all edits (Cmd+Shift+R)": "Скинути всі зміни (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Розумні альбоми оновлюються самі: змініть їхні правила або виберіть Фото → Видалити фото", "LightCraft stopped": "LightCraft зупинився", - "Show Focus Point": "Показати точку фокусування", + "Focus Point": "Точка фокусування", "Zoom to Focus Point": "Збільшити до точки фокусування", "Focus": "Фокус", "Focus point": "Точка фокусування", @@ -2340,5 +2339,17 @@ "Checking the GPU…": "Перевірка ГП…", "CPU": "ЦП", "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.", - "Checking the GPU for faces": "Перевірка ГП для облич" + "Checking the GPU for faces": "Перевірка ГП для облич", + "Layout Image": "Зображення макета", + "Detail View": "Детальне подання", + "Edit Panel": "Панель «Редагування»", + "Close the open dialog, then drop the files again": "Закрийте відкрите вікно та перетягніть файли ще раз", + "Close the open dialog to drop files here": "Закрийте відкрите вікно, щоб перетягнути сюди файли", + "Drop to open the face model": "Відпустіть, щоб відкрити модель облич", + "Drop to import": "Відпустіть для імпорту", + "Dropped Files": "Перетягнуті файли", + "Import dropped files without review": "Імпортувати перетягнуті файли без перегляду", + "An import is running": "Імпорт уже виконується", + "An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться", + "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото" } diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json index 9ff8ed6b9..d5e28bff0 100644 --- a/crates/ui-egui/locales/vi-formats.json +++ b/crates/ui-egui/locales/vi-formats.json @@ -102,6 +102,7 @@ "Imported {} photo{}": "Đã nhập {} ảnh{:.0}", "In line for AI Denoise ({ahead} ahead)…": "Đang xếp hàng Khử nhiễu AI (trước bạn: {ahead})…", "Insert at the cursor (also written {})": "Chèn tại con trỏ (cũng ghi là {})", + "Inside: {names}": "Bên trong: {names}", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Sẽ gỡ khỏi {count} ảnh{:.0}, kèm từ khóa con. Hoàn tác sẽ khôi phục.", "Keywords of {n} selected photos": "Keywords of {n} selected photos", "Label Set: {name}": "Bộ nhãn: {name}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", "• {assumption}": "• {assumption}", "… and {more} more": "… và {more} nữa", - "…and {more} more": "…and {more} more" + "…and {more} more": "…and {more} more", + "Added {n} photo{}": "Đã thêm {n} ảnh{:.0}", + "Could not read {name}: {e}": "Không thể đọc {name}: {e}", + "Drop to import {n} item{}": "Thả để nhập {n} mục{:.0}", + "Drop to import {n} preset{}": "Thả để nhập {n} thiết lập sẵn{:.0}", + "Drop to review {n} item{} for import": "Thả để xem lại {n} mục trước khi nhập{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?", + "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})" } diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index 6908be70e..98adc91b8 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -288,7 +288,6 @@ "Clear Filters": "Xóa bộ lọc", "Clear Guides": "Xóa đường dẫn", "Clear History": "Xóa lịch sử", - "Clear Preview Cache": "Xóa bộ nhớ đệm xem trước", "Clear Quick Collection": "Xóa Bộ sưu tập nhanh", "Clear Recent Libraries": "Xóa thư viện gần đây", "Clear Reference": "Xóa tham chiếu", @@ -342,7 +341,6 @@ "Company": "Công ty", "Compare": "So sánh", "Compare (Shift+C)": "So sánh (Shift+C)", - "Compare Before and After": "So sánh Trước và Sau", "Compare needs at least two photos": "So sánh cần ít nhất hai ảnh", "Component options": "Tùy chọn thành phần", "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Nikon NEF nén (nén không mất và có mất, 12 và 14 bit — ví dụ D3200, D5100, D7000, D750, D850, Z 50)", @@ -1676,17 +1674,12 @@ "Short Edge": "Cạnh ngắn", "Show": "Hiện", "Show All": "Hiện tất cả", - "Show All Mask Overlays": "Hiện mọi lớp phủ mặt nạ", - "Show Clipping": "Hiện vùng cắt tông", "Show Deghost Overlay": "Hiện lớp phủ deghost", "Show Hidden Local Locations": "Hiện vị trí Local đã ẩn", "Show Luminance Map": "Hiện bản đồ độ sáng", "Show Mask": "Hiện mặt nạ", - "Show Mask Overlay": "Hiện lớp phủ mặt nạ", - "Show Mask Pins": "Hiện ghim mặt nạ", "Show Original": "Hiện bản gốc", "Show Person": "Hiện người", - "Show Photo Counts": "Hiện số lượng ảnh", "Show Photos with Keyword": "Hiện ảnh có từ khóa", "Show Pins": "Hiện ghim", "Show Sidebar Section": "Hiện mục thanh bên", @@ -2220,5 +2213,24 @@ "≥ at least": "≥ tối thiểu", "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-kéo để chỉnh thẳng, ⇧G Chỉnh thẳng có hướng dẫn, lưới khi biến đổi.", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "All Mask Overlays": "Mọi lớp phủ mặt nạ", + "Before/After Side by Side": "Trước/Sau cạnh nhau", + "Clipping": "Vùng cắt tông", + "Detail View": "Chế độ xem chi tiết", + "Edit Panel": "Bảng Chỉnh sửa", + "Focus Point": "Điểm lấy nét", + "Layout Image": "Ảnh bố cục", + "Mask Overlay": "Lớp phủ mặt nạ", + "Mask Pins": "Ghim mặt nạ", + "Photo Counts": "Số lượng ảnh", + "An import is running": "Đang có một lần nhập chạy", + "An import is running. Drop photos when it has finished": "Đang có một lần nhập chạy. Hãy thả ảnh khi nó hoàn tất", + "Close the open dialog to drop files here": "Hãy đóng hộp thoại đang mở để thả tệp vào đây", + "Close the open dialog, then drop the files again": "Hãy đóng hộp thoại đang mở, rồi thả lại các tệp", + "Drop to import": "Thả để nhập", + "Drop to open the face model": "Thả để mở mô hình khuôn mặt", + "Dropped Files": "Tệp đã thả", + "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh", + "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index a28b07d2c..7406c39b8 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -227,6 +227,7 @@ "Delete the keyword “{name}”?": "要删除关键字“{name}”吗?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}", "Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…", + "Inside: {names}": "其中:{names}", "“{parent}” has a “{name}” already. Merge the two?": "“{parent}”中已有“{name}”。要合并两者吗?", "There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?", "Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存", "Listing folders — folders: {folders}, files: {files}": "正在列出文件夹 — 文件夹:{folders},文件:{files}", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})", + "Drop to review {n} item{} for import": "松开鼠标以在导入前检查 {n} 个项目{:.0}", + "Drop to import {n} item{}": "松开鼠标以导入 {n} 个项目{:.0}", + "Drop to import {n} preset{}": "松开鼠标以导入 {n} 个预设{:.0}", + "Could not read {name}: {e}": "无法读取 {name}:{e}", + "Added {n} photo{}": "已添加 {n} 张照片{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目录“{catalog}”要导入到哪里?", + "Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”({count} 张照片){:.0}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 4a2ebaa7b..73caf6407 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -220,7 +220,6 @@ "Clear Filters": "清除过滤器", "Clear Guides": "清除参考线", "Clear History": "清除历史记录", - "Clear Preview Cache": "清除预览缓存", "Clear Quick Collection": "清空快速集合", "Clear Reference": "清除参考照片", "Clear all": "全部清除", @@ -270,7 +269,6 @@ "Company": "公司", "Compare": "比较", "Compare (Shift+C)": "比较(Shift+C)", - "Compare Before and After": "比较修改前和修改后", "Compare needs at least two photos": "比较至少需要两张照片", "Component options": "组件选项", "Compression": "压缩", @@ -1351,17 +1349,12 @@ "Short Edge": "短边", "Show": "显示", "Show All": "显示全部", - "Show Clipping": "显示溢出警告", "Show Deghost Overlay": "显示去重影叠加", "Show Hidden Local Locations": "显示隐藏的本地位置", "Show Luminance Map": "显示明亮度图", "Show Mask": "显示蒙版", - "Show Mask Overlay": "显示蒙版叠加", - "Show All Mask Overlays": "显示所有蒙版叠加", "Show all overlays": "显示所有叠加", - "Show Mask Pins": "显示蒙版图钉", "Show Original": "显示原图", - "Show Photo Counts": "显示照片数量", "Show Photos with Keyword": "显示包含该关键字的照片", "Show Pins": "显示图钉", "Show Source": "显示来源", @@ -2153,7 +2146,6 @@ "Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智能相册会自动更新:请更改其规则,或使用“照片 → 删除照片”", "LightCraft stopped": "LightCraft 已停止", - "Show Focus Point": "显示对焦点", "Zoom to Focus Point": "缩放至对焦点", "Focus": "对焦", "Focus point": "对焦点", @@ -2186,5 +2178,24 @@ "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。", "This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。", "Detect Faces (Photo menu) needs it: a small one-time download.": "“检测人脸”(照片菜单)依赖它:一次性小文件下载。", - "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)" + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)", + "Before/After Side by Side": "修改前/修改后左右排列", + "Clipping": "溢出警告", + "Mask Overlay": "蒙版叠加", + "All Mask Overlays": "所有蒙版叠加", + "Mask Pins": "蒙版图钉", + "Photo Counts": "照片数量", + "Focus Point": "对焦点", + "Layout Image": "版面图像", + "Detail View": "详细视图", + "Edit Panel": "编辑面板", + "Close the open dialog, then drop the files again": "请先关闭打开的对话框,然后重新拖入文件", + "Close the open dialog to drop files here": "请先关闭打开的对话框,才能在此拖入文件", + "Drop to open the face model": "松开鼠标以打开人脸模型", + "Drop to import": "松开鼠标以导入", + "Dropped Files": "拖入的文件", + "Import dropped files without review": "不经检查直接导入拖入的文件", + "An import is running": "正在导入", + "An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片", + "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 119272225..d5c4eb279 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -227,6 +227,7 @@ "Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}", "Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…", + "Inside: {names}": "其中:{names}", "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」中已有「{name}」。要合併兩者嗎?", "There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?", "Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體", "Listing folders — folders: {folders}, files: {files}": "正在列出資料夾 — 資料夾:{folders},檔案:{files}", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})", + "Drop to review {n} item{} for import": "放開滑鼠以在匯入前檢查 {n} 個項目{:.0}", + "Drop to import {n} item{}": "放開滑鼠以匯入 {n} 個項目{:.0}", + "Drop to import {n} preset{}": "放開滑鼠以匯入 {n} 個預設集{:.0}", + "Could not read {name}: {e}": "無法讀取 {name}:{e}", + "Added {n} photo{}": "已加入 {n} 張照片{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目錄「{catalog}」要匯入到哪裡?", + "Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」({count} 張照片){:.0}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 78d885128..72cc3ab7f 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -220,7 +220,6 @@ "Clear Filters": "清除篩選條件", "Clear Guides": "清除參考線", "Clear History": "清除歷史紀錄", - "Clear Preview Cache": "清除預覽快取", "Clear Quick Collection": "清除快速集合", "Clear Reference": "清除參考照片", "Clear all": "全部清除", @@ -270,7 +269,7 @@ "Company": "公司", "Compare": "比較", "Compare (Shift+C)": "比較(Shift+C)", - "Compare Before and After": "比較編輯前與編輯後", + "Before/After Side by Side": "編輯前/後左右檢視", "Compare needs at least two photos": "比較需要至少兩張照片", "Component options": "元件選項", "Compression": "壓縮", @@ -405,7 +404,7 @@ "Desert Heat": "沙漠熱浪", "Desert Warmth": "沙漠暖色", "Desktop": "桌面", - "Detail": "詳細檢視", + "Detail": "細節", "Detail (D)": "詳細檢視(D)", "Details": "詳細資訊", "Diagonal": "對角線", @@ -1351,17 +1350,17 @@ "Short Edge": "短邊", "Show": "顯示", "Show All": "全部顯示", - "Show Clipping": "顯示溢色", + "Clipping": "溢色", "Show Deghost Overlay": "顯示去除鬼影疊加", "Show Hidden Local Locations": "顯示隱藏的本機位置", "Show Luminance Map": "顯示明度圖", "Show Mask": "顯示遮色片", - "Show Mask Overlay": "顯示遮色片疊加", - "Show All Mask Overlays": "顯示所有蒙版疊加", + "Mask Overlay": "遮色片疊加", + "All Mask Overlays": "所有遮色片疊加", "Show all overlays": "顯示所有疊加", - "Show Mask Pins": "顯示遮色片控制點", + "Mask Pins": "遮色片控制點", "Show Original": "顯示原始照片", - "Show Photo Counts": "顯示照片數量", + "Photo Counts": "照片數量", "Show Photos with Keyword": "顯示含此關鍵字的照片", "Show Pins": "顯示控制點", "Show Source": "顯示來源", @@ -2115,7 +2114,7 @@ "Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智慧型相簿會自動更新:請變更其規則,或使用「照片 → 刪除照片」", "LightCraft stopped": "LightCraft 已停止", - "Show Focus Point": "顯示對焦點", + "Focus Point": "對焦點", "Zoom to Focus Point": "縮放至對焦點", "Focus": "對焦", "Focus point": "對焦點", @@ -2143,5 +2142,17 @@ "Checking the GPU…": "正在檢查 GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。", - "Checking the GPU for faces": "正在檢查用於臉孔的 GPU" + "Checking the GPU for faces": "正在檢查用於臉孔的 GPU", + "Layout Image": "版面圖像", + "Detail View": "詳細檢視", + "Edit Panel": "編輯面板", + "Close the open dialog, then drop the files again": "請先關閉開啟的對話框,然後重新拖放檔案", + "Close the open dialog to drop files here": "請先關閉開啟的對話框,才能在此拖放檔案", + "Drop to open the face model": "放開滑鼠以開啟人臉模型", + "Drop to import": "放開滑鼠以匯入", + "Dropped Files": "拖放的檔案", + "Import dropped files without review": "不經檢查直接匯入拖放的檔案", + "An import is running": "正在匯入", + "An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片", + "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入" } diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/button.rs index 5ada0b038..ee397a78c 100644 --- a/crates/ui-egui/src/button.rs +++ b/crates/ui-egui/src/button.rs @@ -188,6 +188,7 @@ pub struct Button<'a> { kind: Kind, size: Size, enabled: bool, + busy: bool, widget: Option<String>, tooltip: Option<&'a str>, min_width: f32, @@ -204,6 +205,7 @@ impl<'a> Button<'a> { kind: Kind::default(), size: Size::default(), enabled: true, + busy: false, widget: None, tooltip: None, min_width: 0.0, @@ -256,6 +258,14 @@ impl<'a> Button<'a> { self } + /// Working on what it was pressed for: a spinner takes the icon's place (or sits before the + /// text, inside the button's own padding, so the button keeps its width), a click does nothing, + /// and screen readers hear it is busy. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + /// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on /// screen, as every registered id is. pub fn widget(mut self, id: impl Into<String>) -> Self { @@ -331,14 +341,30 @@ impl<'a> Button<'a> { }; // a button in a disabled part of the window is disabled too let enabled = self.enabled && ui.is_enabled(); - let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() }); + let resp = ui.interact( + rect, + id, + if !enabled { + Sense::hover() + } else if self.busy { + // still reachable by the keyboard, but nothing to press + Sense::focusable_noninteractive() + } else { + Sense::click() + }, + ); let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string(); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, words.clone())); + if self.busy { + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_busy()); + } if let Some(w) = &self.widget { crate::widgets::register(ui.ctx(), w.clone(), rect); } let state = if !enabled { State::Disabled + } else if self.busy { + State::Idle } else if resp.is_pointer_button_down_on() { State::Pressed } else if resp.hovered() { @@ -362,16 +388,29 @@ impl<'a> Button<'a> { } let galley = self.galley(ui); let icon_s = self.size.icon(); - let content_w = match (&galley, self.icon) { - (Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x, - (Some(g), None) => g.size().x, - (None, Some(_)) => icon_s, - (None, None) => 0.0, + // busy: the spinner is the icon's slot; with text alone it is a smaller one in the button's + // own padding (a little tighter), so the button never grows + let spinner_only = self.busy && self.icon.is_none() && galley.is_some(); + let (slot, gap) = if spinner_only { ((self.size.padding() * 2.0 - 12.0).clamp(8.0, icon_s), 4.0) } else { (icon_s, self.size.gap()) }; + let has_slot = self.icon.is_some() || spinner_only; + let content_w = match (&galley, has_slot) { + (Some(g), true) => slot + gap + g.size().x, + (Some(g), false) => g.size().x, + (None, true) => slot, + (None, false) => 0.0, }; let mut x = rect.center().x - content_w / 2.0; - if let Some(icon) = self.icon { - paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content); - x += icon_s + self.size.gap(); + if has_slot { + let slot_rect = Rect::from_center_size(pos2(x + slot / 2.0, rect.center().y), vec2(slot, slot)); + if self.busy { + if ui.is_rect_visible(rect) { + crate::progress::paint_spinner(p, slot_rect, lk.content, ui.input(|i| i.time)); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(33)); + } + } else if let Some(icon) = self.icon { + paint(p, slot_rect, icon, lk.content); + } + x += slot + gap; } if let Some(g) = galley { let at = pos2(x, rect.center().y - g.size().y / 2.0); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f52589a7d..e53b02ad1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -227,6 +227,23 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest app.widgets.iter().filter(|(id, _)| id.contains(filter)).map(|(id, r)| json!({"id": id, "rect": rect_json(*r)})).collect(), )) } + "ui.dropFiles" => { + // every entry must be a string: a script is not told `ok` for a part of what it sent + let Some(paths) = + p.get("paths").and_then(Value::as_array).and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect::<Option<Vec<_>>>()) + else { + return err("ui.dropFiles: paths must be an array of strings"); + }; + if p.get("hover").and_then(Value::as_bool).unwrap_or(false) { + app.drop_hover = paths; + } else { + // the drag ends with the drop, as it does for the OS + app.drop_hover.clear(); + app.drop_pending = paths; + } + ctx.request_repaint(); + ok(Value::Null) + } "ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" | "ui.move" | "ui.click" | "ui.drag" | "ui.pointer" | "ui.key" | "ui.text" | "ui.clipboard" | "ui.scroll" => { // Geometry and input events are resolved only after a fresh completed layout; the @@ -287,8 +304,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. } | crate::state::Dialog::SaveEffect { .. } + | crate::state::Dialog::CaptureTime { .. } )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) + || (r.is_err() && crate::lightroom_destination::keeps_open_on_error(app, &d)) { app.ui.dialog = Some(d); } diff --git a/crates/ui-egui/src/drop_overlay.rs b/crates/ui-egui/src/drop_overlay.rs new file mode 100644 index 000000000..501b99903 --- /dev/null +++ b/crates/ui-egui/src/drop_overlay.rs @@ -0,0 +1,99 @@ +//! What dropping the files dragged over the window will do: the same sorting the drop applies, +//! shown on a veil over the whole window while the drag lasts. + +use egui::{Align2, Color32, Context, Order, Rect, Stroke, StrokeKind, vec2}; + +use crate::LightcraftApp; +use crate::theme::Tokens; + +/// Dropped paths by what becomes of them. +#[derive(Default, Debug, PartialEq)] +pub struct Sorted { + /// Face models (`.onnx`): the first opens the licence dialog. + pub models: Vec<String>, + /// Preset files: imported as presets. + pub presets: Vec<String>, + /// Everything else: photos and folders, for the import. + pub photos: Vec<String>, +} + +/// Sort `paths` as a drop does. +pub fn classify(paths: Vec<String>) -> Sorted { + let (models, rest): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| crate::is_model_file(p)); + let (presets, photos) = crate::split_presets(rest); + Sorted { models, presets, photos } +} + +/// The lines the card shows for files hovering over the window (`unknown`: how many have no path +/// to look at, as on some platforms and in browsers), for the current settings. +fn lines(app: &LightcraftApp, paths: Vec<String>, unknown: bool) -> Vec<String> { + if app.ui.dialog.is_some() { + return vec![crate::i18n::tr("Close the open dialog to drop files here").to_string()]; + } + let drop = classify(paths); + let mut out = Vec::new(); + if !drop.models.is_empty() { + out.push(crate::i18n::tr("Drop to open the face model").to_string()); + } + let plural = |n: usize| if n == 1 { "" } else { "s" }; + // no library open yet (desktop: the web build's drops import into its session as before): + // photos wait for the library chosen, presets are refused (the drop handler does the same) + let waiting = crate::panels::library_problem::holds_drops(app) && !cfg!(target_arch = "wasm32"); + if !drop.presets.is_empty() && waiting { + out.push(crate::i18n::tr("Open a library first, then drop the presets again").to_string()); + } else if !drop.presets.is_empty() { + let n = drop.presets.len(); + out.push(crate::i18n::tr_format!("Drop to import {n} preset{}", plural(n), n = n)); + } + if !drop.photos.is_empty() && waiting { + out.push(crate::i18n::tr("The photos are imported once a library is open").to_string()); + } else if !drop.photos.is_empty() && app.lightroom.is_some() && !cfg!(target_arch = "wasm32") { + // the drop is refused while a Lightroom catalog import owns the catalog + out.push(crate::i18n::tr("Finish Lightroom catalog import before adding photos").to_string()); + } else if !drop.photos.is_empty() && crate::import::running(app) && !cfg!(target_arch = "wasm32") { + out.push(crate::i18n::tr("An import is running. Drop photos when it has finished").to_string()); + } else if !drop.photos.is_empty() { + let n = drop.photos.len(); + // the web build has no review to open: its drops import at once + if app.ui.settings.import_dropped_directly || cfg!(target_arch = "wasm32") { + out.push(crate::i18n::tr_format!("Drop to import {n} item{}", plural(n), n = n)); + } else { + out.push(crate::i18n::tr_format!("Drop to review {n} item{} for import", plural(n), n = n)); + } + } + if out.is_empty() || unknown { + out.push(crate::i18n::tr("Drop to import").to_string()); + } + out +} + +/// Veil and card while files are dragged over the window. +pub fn show(app: &LightcraftApp, ctx: &Context) { + let count = ctx.input(|i| i.raw.hovered_files.len()); + if count == 0 { + return; + } + let known: Vec<String> = ctx.input(|i| { + i.raw.hovered_files.iter().filter_map(|f| f.path.as_ref()).map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect() + }); + let lines = lines(app, known.clone(), known.len() < count); + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let painter = ctx.layer_painter(egui::LayerId::new(Order::Tooltip, egui::Id::unique("drop-overlay"))); + painter.rect_filled(screen, 0.0, Color32::from_black_alpha(150)); + painter.rect_stroke(screen.shrink(10.0), 12.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + let galleys: Vec<_> = lines.into_iter().map(|l| painter.layout(l, t.semibold(18.0), t.text, (screen.width() - 120.0).max(200.0))).collect(); + let (w, h) = galleys.iter().fold((0.0f32, 0.0f32), |(w, h), g| (w.max(g.size().x), h + g.size().y + 8.0)); + let card = Rect::from_center_size(screen.center(), vec2(w + 64.0, h + 40.0)); + painter.rect_filled(card, 10.0, t.chrome); + painter.rect_stroke(card, 10.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + let mut y = card.top() + 24.0; + for g in galleys { + let at = egui::pos2(card.center().x, y); + let height = g.size().y; + painter.galley(Align2::CENTER_TOP.anchor_size(at, g.size()).min, g, t.text); + y += height + 8.0; + } + crate::widgets::register(ctx, "dropOverlay", card); + ctx.request_repaint(); +} diff --git a/crates/ui-egui/src/frame_rate.rs b/crates/ui-egui/src/frame_rate.rs new file mode 100644 index 000000000..69424e958 --- /dev/null +++ b/crates/ui-egui/src/frame_rate.rs @@ -0,0 +1,312 @@ +//! Help ▸ Debug ▸ Frame Rate (`view.frameRate`; `LIGHTCRAFT_FPS=1` starts with it on): a +//! frame-rate display in the window's top-right corner, to watch how a slider drag or a zoom holds +//! up. It shows where a frame's time goes: the UI +//! thread's CPU time (layout, painting, texture uploads) and the rest of the frame interval, which +//! is waiting — for vsync, or for the GPU when render work is ahead of the window's frame. A +//! tick over the graph marks each frame in which a render arrived, so spikes can be put next to +//! renders. While it shows, the window repaints continuously, so the rate is the one a drag gets. + +use std::collections::VecDeque; + +use crate::LightcraftApp; +use crate::render::Slot; + +/// Frames kept for the graph (about two seconds at 120 Hz). +const KEEP: usize = 240; + +/// A gap between frames longer than this is a pause (window hidden or occluded, the machine +/// asleep), not a frame: the display starts counting again instead of showing it as a spike. +const PAUSE: f64 = 2.0; + +#[derive(Clone, Copy)] +pub(crate) struct Frame { + at: f64, + /// Seconds since the previous frame. + pub(crate) dt: f64, + /// The previous frame's CPU time on the UI thread, seconds. + cpu: f64, + /// Renders that arrived this frame. + renders: u64, + /// Of those, thrown away (superseded by a newer request). + discarded: u64, +} + +/// The display's state, kept in the context's temp data (one per window or headless view) and +/// dropped while the display is off, so turning it back on starts a fresh count. +#[derive(Clone, Default)] +struct Hud { + last: Option<f64>, + completed: u64, + discarded: u64, + frames: VecDeque<Frame>, + /// When the numbers were last logged. + logged: Option<f64>, +} + +fn hud_id() -> egui::Id { + egui::Id::unique("fps-hud-state") +} + +/// The display is off: drop its state, so the time it was off never shows as one long frame. +pub(crate) fn forget(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::<Hud>(hud_id())); +} + +/// Record a frame at `now` (`completed`/`discarded`: the renderer's running totals; `cpu`: the +/// previous frame's UI CPU time, seconds). Returns the frames kept for the display, and whether +/// the numbers are due in the log (once a second; A/B runs read them). +fn record(ctx: &egui::Context, now: f64, completed: u64, discarded: u64, cpu: f64) -> (Vec<Frame>, bool) { + ctx.data_mut(|d| { + let hud = d.get_temp_mut_or_default::<Hud>(hud_id()); + match hud.last { + Some(prev) if now - prev > PAUSE || now < prev => hud.frames.clear(), + Some(prev) => { + let renders = completed.saturating_sub(hud.completed); + let thrown = discarded.saturating_sub(hud.discarded); + hud.frames.push_back(Frame { at: now, dt: (now - prev).max(1e-6), cpu, renders, discarded: thrown }); + while hud.frames.len() > KEEP { + hud.frames.pop_front(); + } + } + None => {} + } + hud.last = Some(now); + hud.completed = completed; + hud.discarded = discarded; + let log = hud.logged.is_none_or(|t| now - t >= 1.0 || now < t); + if log { + hud.logged = Some(now); + } + (hud.frames.iter().copied().collect(), log) + }) +} + +/// The frames the display holds now (tests). +#[cfg(test)] +pub(crate) fn frames(ctx: &egui::Context) -> Vec<Frame> { + ctx.data_mut(|d| d.get_temp::<Hud>(hud_id())).map(|h| h.frames.into_iter().collect()).unwrap_or_default() +} + +fn mb(bytes: u64) -> f64 { + bytes as f64 / (1 << 20) as f64 +} + +/// Count this frame and draw the display over everything. +pub fn show(ctx: &egui::Context, app: &LightcraftApp) { + let now = ctx.input(|i| i.time); + let cpu = app.perf.host_frame_ms.unwrap_or(0.0) / 1e3; + let (frames, log) = record(ctx, now, app.renderer.completed, app.renderer.discarded, cpu); + + let recent: Vec<&Frame> = frames.iter().filter(|f| now - f.at <= 1.0).collect(); + let fps_now = frames.last().map_or(0.0, |f| 1.0 / f.dt); + let worst = recent.iter().copied().max_by(|a, b| a.dt.total_cmp(&b.dt)); + let avg_fps = if recent.is_empty() { 0.0 } else { recent.len() as f64 / recent.iter().map(|f| f.dt).sum::<f64>().max(1e-6) }; + let slow = recent.iter().filter(|f| f.dt > 1.0 / 50.0).count(); + let renders_s: u64 = recent.iter().map(|f| f.renders).sum(); + let discarded_s: u64 = recent.iter().map(|f| f.discarded).sum(); + let window = app.renderer.textures.get(&Slot::Region); + let mem = lightcraft_engine::gpu::memory(); + // (`ready` first: `available` would wait for a device still being created) + let device = match lightcraft_engine::gpu::unavailable_reason() { + Some(why) => format!("off ({why})"), + None if lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available() => { + lightcraft_engine::gpu::adapter_name().unwrap_or_else(|| "on".into()) + } + None => "starting…".into(), + }; + if log { + let (wdt, wcpu) = worst.map_or((0.0, 0.0), |w| (w.dt * 1e3, w.cpu * 1e3)); + let p95 = { + let mut d: Vec<f64> = recent.iter().map(|f| f.dt * 1e3).collect(); + d.sort_by(f64::total_cmp); + d.get(d.len().saturating_sub(1) * 95 / 100).copied().unwrap_or(0.0) + }; + log::info!( + "fps-hud: frames {} slow>20ms {slow} p95 {p95:.1} worst {wdt:.1} (cpu {wcpu:.1}) renders {renders_s} discarded {discarded_s} window {:.1} gpu {device}", + recent.len(), + window.map_or(0.0, |t| t.ms) + ); + } + let lines = [ + format!("{fps_now:>4.0} fps avg {avg_fps:.0} {slow} frames > 20 ms in 1 s"), + match worst { + Some(w) => format!("worst {:.1} ms = CPU {:.1} + wait {:.1}", w.dt * 1e3, w.cpu * 1e3, (w.dt - w.cpu).max(0.0) * 1e3), + None => "worst –".to_string(), + }, + format!("UI CPU {:.1} ms (update {:.1})", app.perf.host_frame_ms.unwrap_or(0.0), app.perf.update_ms), + format!("render frame {:.1} ms", app.renderer.last_main_ms), + match window { + Some(t) => format!("render window {:.1} ms", t.ms), + None => "render window –".to_string(), + }, + format!("renders: {} queued, {} running, {renders_s}/s ({discarded_s} thrown away)", app.renderer.queued(), app.renderer.in_flight()), + format!("GPU: {device}"), + format!("GPU mem {:.0} MB (+{:.0} pooled)", mb(mem.allocated), mb(mem.pooled)), + ]; + + let screen = ctx.content_rect(); + let width = 300.0; + egui::Area::new(egui::Id::unique("fps-hud")) + .order(egui::Order::Tooltip) + .fixed_pos(egui::pos2(screen.right() - width - 24.0, screen.top() + 56.0)) + .interactable(false) + .show(ctx, |ui| { + egui::Frame::new().fill(egui::Color32::from_black_alpha(210)).corner_radius(6.0).inner_margin(8.0).show(ui, |ui| { + ui.set_width(width); + ui.spacing_mut().item_spacing.y = 1.0; + for l in &lines { + ui.label(egui::RichText::new(l).monospace().size(11.0).color(egui::Color32::from_gray(230))); + } + ui.add_space(4.0); + graph(ui, &frames, width); + ui.label( + egui::RichText::new("bars: blue = UI CPU, orange/red = waiting · ticks = a render arrived · line = 60 fps") + .size(9.0) + .color(egui::Color32::from_gray(160)), + ); + }); + }); + // a rate the drag gets, not the idle one + ctx.request_repaint(); +} + +/// Frame intervals, 0 at the bottom and 50 ms at the top: the UI thread's CPU part in blue and +/// the waiting part above it (green within 60 fps, orange within 30, red beyond), with a tick +/// over each frame in which a render arrived. +fn graph(ui: &mut egui::Ui, frames: &[Frame], width: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 70.0), egui::Sense::hover()); + let p = ui.painter_at(rect); + let plot = egui::Rect::from_min_max(egui::pos2(rect.left(), rect.top() + 6.0), rect.max); + let y = |ms: f64| plot.bottom() - (ms / 50.0).clamp(0.0, 1.0) as f32 * plot.height(); + let w = rect.width() / KEEP as f32; + let start = KEEP.saturating_sub(frames.len()); + for (i, f) in frames.iter().enumerate() { + let (ms, cpu) = (f.dt * 1e3, (f.cpu * 1e3).min(f.dt * 1e3)); + let x = rect.left() + (start + i) as f32 * w; + let bar = |from: f64, to: f64, c: egui::Color32| { + p.rect_filled(egui::Rect::from_min_max(egui::pos2(x, y(to)), egui::pos2(x + w.max(1.0), y(from))), 0.0, c); + }; + let wait = if ms > 33.4 { + egui::Color32::from_rgb(230, 80, 70) + } else if ms > 17.5 { + egui::Color32::from_rgb(235, 160, 60) + } else { + egui::Color32::from_rgb(110, 190, 110) + }; + bar(cpu, ms, wait); + bar(0.0, cpu, egui::Color32::from_rgb(90, 150, 240)); + if f.renders > 0 { + p.rect_filled( + egui::Rect::from_min_max(egui::pos2(x, rect.top()), egui::pos2(x + w.max(1.0), rect.top() + 4.0)), + 0.0, + egui::Color32::WHITE, + ); + } + } + p.hline(rect.x_range(), y(1000.0 / 60.0), egui::Stroke::new(1.0, egui::Color32::from_rgb(90, 200, 90))); + p.hline(rect.x_range(), y(1000.0 / 30.0), egui::Stroke::new(1.0, egui::Color32::from_rgb(200, 90, 80).gamma_multiply(0.6))); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use serde_json::json; + + use crate::headless::Headless; + use crate::menubar::{MenuNode, menu_bar}; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + fn set(h: &mut Headless, params: serde_json::Value) -> Option<bool> { + let r = h.request("engine.execute", json!({"command": "view.frameRate", "params": params}), T); + assert_eq!(r["ok"], true, "{r}"); + r["result"]["show"].as_bool() + } + + /// The Help ▸ Debug item for it, and its checkmark. + fn debug_item(app: &LightcraftApp) -> Option<Option<bool>> { + let help = menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default(); + help.into_iter().find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Debug" => children.into_iter().find_map(|c| match c { + MenuNode::Item { id, checked, .. } if id == "view.frameRate" => Some(checked), + _ => None, + }), + _ => None, + }) + } + + /// Help ▸ Debug ▸ Frame Rate toggles the display (a checked menu item), `{show}` sets it, + /// and frames draw with it on. + #[test] + fn show_frame_rate_is_a_debug_toggle() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let was = h.app.ui.frame_rate; + assert_eq!(debug_item(&h.app), Some(Some(was)), "Help ▸ Debug lists it, checked as it is"); + assert_eq!(set(&mut h, json!({})), Some(!was)); + assert_eq!(debug_item(&h.app), Some(Some(!was))); + // frames with the display on draw (and keep the window repainting) + assert_eq!(set(&mut h, json!({"show": true})), Some(true)); + for _ in 0..3 { + h.step(); + } + assert_eq!(set(&mut h, json!({"show": false})), Some(false)); + assert!(!h.app.ui.frame_rate); + } + + fn worst_ms(h: &Headless) -> f64 { + super::frames(&h.view.ctx).iter().map(|f| f.dt * 1e3).fold(0.0, f64::max) + } + + /// Turned off and on again later, the display starts a fresh count: the time it was off is not + /// one long frame (a red spike and a "worst" of the whole time off). + #[test] + fn turning_it_back_on_shows_no_spike_for_the_time_it_was_off() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + set(&mut h, json!({"show": true})); + for _ in 0..3 { + h.step(); + } + assert!(!super::frames(&h.view.ctx).is_empty(), "frames are counted while it shows"); + set(&mut h, json!({"show": false})); + h.step(); + h.time += 1.5; + set(&mut h, json!({"show": true})); + for _ in 0..3 { + h.step(); + } + assert!(worst_ms(&h) < 1000.0, "worst frame {} ms after turning it back on", worst_ms(&h)); + } + + /// A pause while it shows (window hidden, machine asleep) starts a fresh count too. + #[test] + fn a_long_pause_while_it_shows_is_not_a_frame() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + set(&mut h, json!({"show": true})); + for _ in 0..3 { + h.step(); + } + h.time += 30.0; + for _ in 0..3 { + h.step(); + } + assert!(worst_ms(&h) < 1000.0, "worst frame {} ms after a pause", worst_ms(&h)); + } + + /// Each window (egui context) keeps its own count. + #[test] + fn each_context_keeps_its_own_count() { + let (a, b) = (egui::Context::default(), egui::Context::default()); + for t in [0.0, 0.016, 0.033] { + super::record(&a, t, 0, 0, 0.0); + } + assert_eq!(super::frames(&a).len(), 2); + assert!(super::frames(&b).is_empty()); + let (frames, _) = super::record(&b, 100.0, 0, 0, 0.0); + assert!(frames.is_empty(), "a new context's first frame has no interval: {}", frames.len()); + } +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 947c01455..9dc840643 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -246,14 +246,15 @@ pub struct Headless { /// Logical size (points) and scale. pub size: egui::Vec2, pub pixels_per_point: f32, - time: f64, + pub(crate) time: f64, frames: u64, pub(crate) events: Vec<egui::Event>, control: Sender<ControlRequest>, quit: bool, /// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host). pub window_maximized: bool, - /// Window-management commands the app sent (`StartDrag`, `Maximized`, …), oldest first. + /// Window-management and pointer commands the app sent (`StartDrag`, `Maximized`, + /// `CursorPosition`, …), oldest first. pub window_commands: Vec<ViewportCommand>, /// The pointer cursor the last frame asked for (what a real host would show). pub last_cursor: egui::CursorIcon, @@ -355,7 +356,8 @@ impl Headless { self.window_maximized = on; self.window_commands.push(c); } - ViewportCommand::StartDrag => self.window_commands.push(c), + // recorded, not carried out: the simulated pointer moves only when a test moves it + ViewportCommand::StartDrag | ViewportCommand::CursorPosition(_) | ViewportCommand::CursorGrab(_) => self.window_commands.push(c), _ => {} } } @@ -2033,6 +2035,30 @@ mod tests { assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); } + /// With `label` and `labels` both set (a saved single-label filter, or an agent), the filter + /// bar lights only the labels that pass both, and a click starts from those, not their union. + #[test] + fn filter_bar_labels_from_label_and_labels() { + use lightcraft_catalog::ColorLabel; + let mut h = demo([1400.0, 800.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "view.filterBar"}), t); + h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["red", "yellow"]})).unwrap(); + // a frame so the bar is laid out for the new filter before the click + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); + assert_eq!(r["ok"], true, "{r}"); + let f = h.app.session.filter.clone(); + assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Red, ColorLabel::Green])); + // nothing passes both: nothing is lit, so a click shows just that label + h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["yellow"]})).unwrap(); + h.step(); + h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); + let f = h.app.session.filter.clone(); + assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Green])); + h.settle(SETTLE); + } + /// The filter bar drives `library.filter` and saves the view as a smart album. #[test] fn filter_bar_filters_and_saves_a_smart_album() { @@ -3395,7 +3421,7 @@ mod tests { } /// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it - /// narrower, though the tabs are of different heights and widths. + /// narrower or taller, though the tabs hold different amounts: the window is one size on every tab. #[test] fn settings_tabs_keep_the_window_in_place() { let mut h = demo([1300.0, 820.0]); @@ -3414,7 +3440,7 @@ mod tests { assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width()); heights.push(now.height()); } - assert!(heights.iter().any(|h| (h - first.height()).abs() > 20.0), "the tabs differ in height: {heights:?}"); + assert!(heights.iter().all(|h| (h - first.height()).abs() < 1.0), "the tabs keep the height of {}: {heights:?}", first.height()); } /// Settings → Import → "Show the import review when a camera or card is connected": a card that diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3614637a0..9ba1cd5af 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -266,8 +266,48 @@ fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> { // `locales/*-formats.json`. include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); -/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays -/// exactly as its owner wrote it. +/// The web build's notice for a dropped file the browser could not read. +pub fn dropped_file_unreadable(name: &str, error: &str) -> String { + tr_format!("Could not read {name}: {e}", name = name, e = error) +} + +/// The web build's notice for dropped files the library refused. +pub fn dropped_import_failed(error: &str) -> String { + tr_format!("Import failed: {e}", e = error) +} + +/// The web build's notice for `n` dropped photos that joined the library. +pub fn dropped_photos_added(n: usize) -> String { + tr_format!("Added {n} photo{}", if n == 1 { "" } else { "s" }, n = n) +} + +/// The web build's notice for `n` dropped photos that came back from Recently Deleted. +pub fn dropped_photos_restored(n: usize) -> String { + tr_format!("Restored {} photo{} from Recently Deleted", n, if n == 1 { "" } else { "s" }) +} + +/// The web build's notice for what a drop of photos came to, from the `library.import` report: every outcome that +/// happened (added, restored from Recently Deleted, not readable), joined with " · " as the desktop import's notice +/// joins its parts, or `None` when none did (duplicates only). +pub fn dropped_photos_outcome(report: &serde_json::Value) -> Option<String> { + let count = |k: &str| report[k].as_array().map_or(0, Vec::len); + let parts: Vec<String> = [ + (count("imported"), dropped_photos_added as fn(usize) -> String), + (count("restored"), dropped_photos_restored), + (count("failed"), dropped_photos_unreadable), + ] + .into_iter() + .filter(|(n, _)| *n > 0) + .map(|(n, say)| say(n)) + .collect(); + (!parts.is_empty()).then(|| parts.join(" · ")) +} + +/// The web build's notice for `n` dropped photos that could not be read. +pub fn dropped_photos_unreadable(n: usize) -> String { + tr_format!("{} photo{} not readable", n, if n == 1 { "" } else { "s" }) +} + /// The title and text of the message box shown when the desktop app's window can't start /// (issue #260), in the UI language: the error and, when there is one, the log file to look in. pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, String) { @@ -305,6 +345,8 @@ pub fn upper_first(text: &str) -> String { } } +/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays +/// exactly as its owner wrote it. pub fn builtin_label(source: &str, builtin: bool) -> &str { if builtin { tr(source) } else { source } } @@ -1107,6 +1149,20 @@ mod tests { let no = format!(" {}", bool_text_in(Locale::De, false)); assert!(rules_label(&rules, &catalog).ends_with(&no), "{}", rules_label(&rules, &catalog)); } + let both = lightcraft_catalog::Filter { + label: Some(lightcraft_catalog::ColorLabel::Red), + labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Red], + ..Default::default() + }; + let name = |label| color_label(&app.session.catalog, label); + let (red, blue, green) = + (name(lightcraft_catalog::ColorLabel::Red), name(lightcraft_catalog::ColorLabel::Blue), name(lightcraft_catalog::ColorLabel::Green)); + let (label, and, or) = (tr("Label"), tr("and"), tr("or")); + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red}")); + let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue], ..both }; + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} {blue}")); + let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Green], ..both }; + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} ({blue} {or} {green})")); let filter = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], ..Default::default() @@ -1624,6 +1680,95 @@ mod ", let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)"); assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]); } + + /// A drop's notice comes from what `library.import` reported: a photo added, one brought back from Recently + /// Deleted and a file that could not be read are each named; a drop of duplicates only says nothing. + #[test] + fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_duplicates_only() { + set_language(Locale::En); + let dir = std::env::temp_dir().join(format!("lc-drop-notice-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = |name: &str, seed: u8| { + let data: Vec<[u8; 4]> = (0..48 * 32).map(|i| [(i % 48 * 5) as u8, (i / 48 * 7) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 48, height: 32, data }; + let bytes = + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(name), bytes).unwrap(); + dir.join(name).to_string_lossy().into_owned() + }; + let (trashed, added) = (png("trashed.png", 1), png("new.png", 2)); + let broken = dir.join("broken.jpg"); + std::fs::write(&broken, b"not a jpeg at all").unwrap(); + let mut s = lightcraft_engine::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [trashed]})).unwrap(); + s.execute("photo.delete", &serde_json::json!({"ids": [r["imported"][0]]})).unwrap(); + let report = s.execute("library.import", &serde_json::json!({"paths": [trashed, added, broken]})).unwrap(); + assert_eq!( + dropped_photos_outcome(&report).as_deref(), + Some("Added 1 photo · Restored 1 photo from Recently Deleted · 1 photo not readable"), + "{report}" + ); + let again = s.execute("library.import", &serde_json::json!({"paths": [added]})).unwrap(); + assert_eq!(dropped_photos_outcome(&again), None, "a duplicate only: {again}"); + let _ = std::fs::remove_dir_all(dir); + } + + /// Polish: the menu and control channel select it, dates read day.month.year, and every count + /// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so + /// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. + #[test] + fn polish_switches_formats_dates_and_reads_every_count() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.polish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Pl); + assert_eq!(Locale::Pl.name(), "Polski"); + assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); + assert_eq!(tr("File"), "Plik"); + assert_eq!(tr("Exposure"), "Ekspozycja"); + assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); + assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); + for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] { + let s = if n == 1 { "" } else { "s" }; + assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}")); + for (one, many, noun) in [ + ("photo waiting", "photos waiting", "Oczekujące zdjęcia"), + ("cached picture", "cached pictures", "Obrazy w pamięci podręcznej"), + ("photo has its denoised picture.", "photos have their denoised picture.", "Zdjęcia z odszumioną wersją"), + ] { + assert_eq!(tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })), format!("{noun}: {n}")); + } + assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})")); + assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}")); + } + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Pl); + let ctx = egui::Context::default(); + for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Pl); + } + let text = painted_text(&ctx, &mut app, Locale::Pl); + assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{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 ch in "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ„”".chars() { + assert!(fonts.has_glyph(&font, ch), "missing Polish glyph {ch}"); + } + } + }); + set_language(Locale::En); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index ffbfbf28a..b707a589e 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -91,6 +91,10 @@ pub struct ImportDialog { /// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]). #[serde(skip)] pub last_clicked: Option<usize>, + /// The library the scan was made in ([`lightcraft_engine::Session::library_generation`]): its duplicate + /// decisions and album ids belong to that catalog, so the review is not shown or confirmed in another. + #[serde(skip)] + pub library: Option<u64>, } /// A candidate's file type, as the review groups them: its format, or its extension when the @@ -114,6 +118,11 @@ impl ImportDialog { let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect(); ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() } } + + /// Whether the library the scan was made in is still the open one (a review that names none counts). + fn in_library(&self, app: &LightcraftApp) -> bool { + self.library.is_none_or(|g| g == app.session.library_generation()) + } /// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice /// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by /// mistake comes back by importing its file again (issue #706). @@ -471,6 +480,9 @@ pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String if app.scan.is_some() { return Err("a scan is already running".into()); } + if app.review_pending.is_some() { + return Err("an import review is waiting for the open dialog to close".into()); + } let sources = paths.clone(); let (input, paths) = ScanInput::new(&mut app.session, &paths); let progress = std::sync::Arc::new(ScanProgress::default()); @@ -556,6 +568,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> /// Collect a finished scan and open the review (called every frame). pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { + show_pending_review(app, ctx); let Some(task) = app.scan.as_ref() else { return }; ctx.request_repaint_after(std::time::Duration::from_millis(100)); let out = match task.rx.try_recv() { @@ -626,7 +639,52 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { // only an album the review offers (it may have been deleted while the scan ran) d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); d.sources = task.sources; - app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); + d.library = Some(app.session.library_generation()); + app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); + show_pending_review(app, ctx); +} + +/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… +/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was +/// closed meanwhile (Settings can open another) is dropped. +fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.review_pending.is_none() { + return; + } + if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending + && !opts.in_library(app) + { + app.review_pending = None; + return; + } + if app.ui.dialog.is_none() { + app.ui.dialog = app.review_pending.take(); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running. +#[cfg(test)] +pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box<dyn std::any::Any> { + stall_scan_of(app, false) +} + +/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan. +#[cfg(test)] +pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box<dyn std::any::Any> { + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel::<ScanOutput>(); + let guard = scan_guard(app, &progress, false); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard }); + Box::new(tx) +} + +/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's +/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan +/// runs, and a browse that finishes replaces `app.import`. +pub fn running(app: &LightcraftApp) -> bool { + app.import.is_some() || app.scan.is_some() || app.review_pending.is_some() } impl ScanTask { @@ -636,9 +694,17 @@ impl ScanTask { } } -/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). +/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another +/// import runs: replacing `app.import` would stop its worker. pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String> { busy_synchronizing(app)?; + if !d.in_library(app) { + return Err("another library was opened since the photos were scanned: nothing was imported".into()); + } + // a browse scan that finishes starts its own import, replacing this one + if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) { + return Err(crate::i18n::tr("An import is running").to_string()); + } let queue = d.selected_paths(); if queue.is_empty() { return Err("no photos selected".into()); @@ -691,8 +757,8 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String> /// 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> { busy_synchronizing(app)?; - if app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) { - return Err("an import is running".into()); + if running(app) { + return Err(crate::i18n::tr("An import is running").to_string()); } let undo0 = app.session.undo.len(); let total = paths.len(); @@ -1340,7 +1406,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial crate::widgets::announce(&resp, c.name.clone()); register(ui.ctx(), format!("import:{i}"), img); let p = ui.painter(); - p.rect_filled(img, 3.0, Color32::from_gray(30)); + p.rect_filled(img, 3.0, t.skeleton); // thumbnail (background job; the grid only asks for the cells in view) let slot = Slot::Import(i as u32); if !app.renderer.textures.contains_key(&slot) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index daeb3de74..69aa14554 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,12 +15,15 @@ pub mod control; pub mod credits; pub mod date_picker; pub mod docking; +mod drop_overlay; pub mod export_task; mod focus_scan; +mod frame_rate; pub mod headless; pub mod i18n; pub mod icons; pub mod import; +pub mod lightroom_destination; pub mod lightroom_import; pub mod links; pub mod menu_level; @@ -31,6 +34,8 @@ mod model_setup; pub mod panels; pub mod pick; pub mod print_view; +pub mod progress; +pub mod rating; pub mod recheck; pub mod region; pub mod reload; @@ -65,6 +70,10 @@ mod tests_button; #[cfg(test)] mod tests_canvas_keys; #[cfg(test)] +mod tests_canvas_pan; +#[cfg(test)] +mod tests_capture_time; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; @@ -81,10 +90,14 @@ mod tests_dialog_fit; #[cfg(test)] mod tests_dialog_primary; #[cfg(test)] +mod tests_dialog_settings; +#[cfg(test)] mod tests_dialog_tab; #[cfg(test)] mod tests_dialogs; #[cfg(test)] +mod tests_drop_import; +#[cfg(test)] mod tests_edit_layout; #[cfg(test)] mod tests_empty_library; @@ -103,6 +116,8 @@ mod tests_grid; #[cfg(test)] mod tests_guided_guides; #[cfg(test)] +mod tests_histogram_drag; +#[cfg(test)] mod tests_info_dialogs; #[cfg(test)] mod tests_keymap; @@ -119,12 +134,16 @@ mod tests_keywording; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_launch_library; +#[cfg(test)] mod tests_layout_image; #[cfg(test)] mod tests_library_problem; #[cfg(test)] mod tests_lifecycle_037; #[cfg(test)] +mod tests_lightroom_destination; +#[cfg(test)] mod tests_lights_out; #[cfg(test)] mod tests_mask_effects; @@ -135,12 +154,16 @@ mod tests_masking_layout; #[cfg(test)] mod tests_menubar; #[cfg(test)] +mod tests_merge_preview; +#[cfg(test)] mod tests_offline; #[cfg(test)] mod tests_open_lightroom; #[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_preset_favorites; +#[cfg(test)] mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; @@ -151,8 +174,12 @@ mod tests_profile_menu; #[cfg(test)] mod tests_profiles_layout; #[cfg(test)] +mod tests_progress; +#[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_rating; +#[cfg(test)] mod tests_recheck; #[cfg(test)] mod tests_rename_person; @@ -420,6 +447,11 @@ pub struct LightcraftApp { shadow: Option<headless::HeadlessView>, /// Synthetic input events (from the control channel) injected one step per frame. pub synthetic: Vec<egui::Event>, + /// Files the control channel (`ui.dropFiles`) holds over the window: every frame reports them + /// as hovered, as the OS does while a drag is over the window. + drop_hover: Vec<String>, + /// Files the control channel dropped: delivered as `dropped_files` on the next frame. + drop_pending: Vec<String>, /// The text the UI last put on the clipboard (`ui.inspect` → `copied`). pub copied: Option<String>, /// Native file dialogs up for commands (`pick`): each command runs again when its closes. @@ -489,6 +521,8 @@ pub struct LightcraftApp { pub lightroom_last: Option<Value>, /// A completed import result waiting for another modal dialog to close. pub(crate) lightroom_result: Option<state::Dialog>, + /// The import review of a finished scan, waiting for another dialog to close. + pub(crate) review_pending: Option<state::Dialog>, /// The library generation the window title and the recent list were last updated for. pub shown_library: Option<u64>, /// A background export in progress. @@ -522,6 +556,12 @@ pub struct LightcraftApp { /// 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>, + /// Alt/Option held as the window first appears offers the recent libraries once (issue + /// #1290); the desktop host arms it when nothing chose the library already. + pub alt_asks_for_library: bool, + /// Whether each recent library can be opened, answered by a worker (issue #1290): the launch + /// list and File ▸ Open Recent Library never look at a folder on the UI thread. + pub recent_checks: panels::library_problem::RecentChecks, /// The activity stack shows every task, not just the first few ("+N more" was clicked). pub activity_expanded: bool, } @@ -564,6 +604,8 @@ impl LightcraftApp { ui_passes: 0, shadow: None, synthetic: vec![], + drop_hover: vec![], + drop_pending: vec![], copied: None, pending_picks: vec![], synthetic_mods: egui::Modifiers::NONE, @@ -599,6 +641,7 @@ impl LightcraftApp { lightroom: None, lightroom_last: None, lightroom_result: None, + review_pending: None, shown_library: None, export: None, tasks: Default::default(), @@ -614,6 +657,8 @@ impl LightcraftApp { screen_profile: None, screen_profile_read: None, library_problem: None, + alt_asks_for_library: false, + recent_checks: Default::default(), activity_expanded: false, model_setup: Default::default(), } @@ -814,7 +859,7 @@ impl LightcraftApp { // be invisible once the activity stack is gone. Never show success on error. let completed = match id { "library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))), - "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Clear Preview Cache"))), + "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Discard Preview Cache"))), "develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))), _ => None, }; @@ -1644,7 +1689,6 @@ impl LightcraftApp { // then draws with the new visuals. `ui` keeps its own call for a settings change made by // a command during the same pass; it is a no-op when the theme already matches. self.sync_theme(ctx); - panels::library_problem::logic(self); self.logic_background(ctx); self.perf.logic_ms = monotonic_ms() - t0; } @@ -1847,6 +1891,9 @@ impl LightcraftApp { panels::faces::pump_ui_input(self, ctx, now); self.import_dropped_files(ctx); self.check_external_editor(ctx); + // Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI + // phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract). + panels::library_problem::logic(self, ctx); if self.fonts_ready { shortcuts::handle(self, ctx); } @@ -1866,37 +1913,63 @@ impl LightcraftApp { return; } #[cfg(not(target_arch = "wasm32"))] - { - let dropped: Vec<String> = - dropped.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect(); - // preset files import as presets, everything else as photos - // a dropped face model opens its licence dialog; presets and photos as before - let (models, dropped): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_model_file(p)); - if let Some(model) = models.first() - && let Err(e) = panels::faces::open_dialog(self, model) - { - self.toast(ctx, e); - } - let (presets, photos) = split_presets(dropped); - if !presets.is_empty() { - let _ = self.run("file.importPresets", serde_json::json!({"paths": presets})); - } - // read and added on a worker thread (dropped folders can be large, or on a slow drive) - if !photos.is_empty() { - if self.lightroom.is_some() { - self.toast(ctx, "Finish Lightroom catalog import before adding photos"); - } else if let Err(e) = import::start_paths(self, photos) { - self.toast(ctx, e); - } - } - } - // The browser host (`lightcraft-web`'s `WebApp::ui`) consumes the same app queue first - // because it needs `bytes_async`; a future wasm host must do the same, not call this and - // silently discard the drops. + self.handle_dropped_files(ctx, dropped); #[cfg(target_arch = "wasm32")] let _ = (dropped, ctx); } + /// Files dropped on the window: face models open their licence dialog, presets import as + /// presets, photos and folders go to the import review (or straight in, Settings ▸ Import). + #[cfg(not(target_arch = "wasm32"))] + fn handle_dropped_files(&mut self, ctx: &egui::Context, dropped: Vec<egui::DroppedFileHandle>) { + let dropped: Vec<String> = + dropped.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect(); + if dropped.is_empty() { + return; + } + // a dialog is on screen: the review would replace it, losing what it holds + if self.ui.dialog.is_some() { + self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again")); + return; + } + let drop = drop_overlay::classify(dropped); + if let Some(model) = drop.models.first() + && let Err(e) = panels::faces::open_dialog(self, model) + { + self.toast(ctx, e); + } + if !drop.presets.is_empty() { + // presets live in a library: with none open they would go with the session it replaces + if panels::library_problem::holds_drops(self) { + self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again")); + } else { + let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets})); + } + } + if drop.photos.is_empty() { + return; + } + // no library open yet (the launch list, a library that failed to open): they wait for one + let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return }; + if self.lightroom.is_some() { + self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos")); + return; + } + if import::running(self) { + self.toast(ctx, crate::i18n::tr("An import is running")); + return; + } + let started = if self.ui.settings.import_dropped_directly { + // read and added on a worker thread (dropped folders can be large, or on a slow drive) + import::start_paths(self, photos).map(|_| ()) + } else { + import::open(self, photos).map(|_| ()) + }; + if let Err(e) = started { + self.toast(ctx, e); + } + } + /// A file dropped by the host, kept for the next UI pass (web hosts consume their own queue). fn queue_dropped_files(&mut self, raw: &mut egui::RawInput) { if !raw.dropped_files.is_empty() { @@ -2008,6 +2081,17 @@ impl LightcraftApp { /// keeps that batch in flight until a completed UI pass consumed it. It never injects while /// the fonts are not installed (the pass would draw nothing and the events would be lost). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p)); + if !self.drop_pending.is_empty() { + raw.dropped_files = std::mem::take(&mut self.drop_pending) + .into_iter() + .map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle) + .collect(); + } + if !self.drop_hover.is_empty() { + raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect(); + } + // Queue after injecting, so a `ui.dropFiles` request is consumed exactly once by the UI phase. self.queue_dropped_files(raw); if !self.fonts_ready || self.synthetic_in_flight { return; @@ -2082,6 +2166,12 @@ impl LightcraftApp { /// Frame timings once layout is done (`t0`: when layout started). fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { + if self.ui.frame_rate { + frame_rate::show(ctx, self); + } else { + frame_rate::forget(ctx); + } + panels::edit::wheel_pointer_frame_end(ctx); ctx.output(|o| { for c in &o.commands { if let egui::OutputCommand::CopyText(text) = c { @@ -2105,6 +2195,12 @@ impl LightcraftApp { if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { let path = l.dir.to_string_lossy().into_owned(); panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + // it is open: whatever an earlier check said about it is over + self.recent_checks.opened(&path); + // when, for the launch window (issue #1290); only the listed libraries keep a time + let settings = &mut self.ui.settings; + settings.recent_opened.insert(path, (self.session.clock)()); + settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); } ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); } @@ -2117,6 +2213,18 @@ impl LightcraftApp { } } + /// The browser page's title: "<file name>[ •] — LightCraft" for the active photo (• while + /// `unsaved`: changes not yet written to storage), else the window title. The web build has no + /// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which + /// document a drop opened and whether it is saved (issue #2397). + pub fn page_title(&self, unsaved: bool) -> String { + let dot = if unsaved { " •" } else { "" }; + match self.session.active().and_then(|id| self.session.catalog.photo(id)) { + Some(p) => format!("{}{dot} — LightCraft", p.file_name), + None => format!("{}{dot}", self.window_title()), + } + } + /// Lay out the whole window: input/setup for the known UI phase, then presentation, then the /// frame bookkeeping. A hidden `run_logic` never reaches this. pub fn ui(&mut self, ui: &mut egui::Ui) { @@ -2169,6 +2277,7 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.end_frame(&ctx, t0); return; @@ -2196,6 +2305,7 @@ impl LightcraftApp { panels::left::section_drag_feedback(self, &ctx); panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.text_focus = ctx.egui_wants_keyboard_input(); self.end_frame(&ctx, t0); @@ -2439,6 +2549,26 @@ fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) -> } } +/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`. +#[derive(Debug)] +struct InjectedDrop(std::path::PathBuf); + +impl egui::DroppedFile for InjectedDrop { + fn path(&self) -> &std::path::Path { + &self.0 + } + + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result<Vec<u8>, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<u8>, String>> + '_>> { + Box::pin(async { Err("the web build has no files to read".to_string()) }) + } +} + /// A dropped face model file (`.onnx`). pub fn is_model_file(path: &str) -> bool { std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) diff --git a/crates/ui-egui/src/lightroom_destination.rs b/crates/ui-egui/src/lightroom_destination.rs new file mode 100644 index 000000000..a62c59825 --- /dev/null +++ b/crates/ui-egui/src/lightroom_destination.rs @@ -0,0 +1,366 @@ +//! File ▸ Import Lightroom Catalog…: where the catalog goes (issue #2672). +//! +//! Before the import starts, a window asks whether the catalog's photos, collections and edits +//! merge into the library open now or go into a new library, named after the catalog and put next +//! to the open one. The new library is made and opened as File ▸ New Library… does +//! (`app.newLibrary`: refused in a folder that isn't empty, or while another import runs), then the +//! catalog is imported into it. Settings ▸ General ▸ Library (or the window's "Don't ask again") +//! turns the question off: the catalog then merges into the open library, as it always did. +//! +//! Only the app command `file.importLightroom` changes: it takes `{into?: "open" | "new", name?, +//! location?}` so the control channel can make the choice without the window. The import itself +//! (`library.importLightroom`, [`crate::lightroom_import`]) is unchanged. A session with no +//! library on disk (in memory) has nothing to put a new library next to: it merges without asking. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::{Dialog, LightroomImport}; + +/// Where `file.importLightroom {into}` puts the catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Destination { + /// `"open"`: merge into the library open now. + Open, + /// `"new"`: a new library, opened before the import. + New, +} + +/// The destination params of `file.importLightroom`, checked. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Request { + /// `into`; `None`: ask (or merge, as the setting says). + pub into: Option<Destination>, + /// `name`: the new library's name (default: the catalog file's name without `.lrcat`). + pub name: Option<String>, + /// `location`: the folder the new library goes in (default: the open library's parent folder). + pub location: Option<String>, +} + +impl Request { + /// Read `{into?, name?, location?}` from `file.importLightroom`'s params. A wrong type or an + /// unknown `into` is an error, before any file dialog opens. + pub fn parse(p: &Value) -> Result<Request, String> { + let into = match p.get("into") { + None | Some(Value::Null) => None, + Some(Value::String(s)) if s == "open" => Some(Destination::Open), + Some(Value::String(s)) if s == "new" => Some(Destination::New), + Some(v) => return Err(format!("file.importLightroom: `into` must be \"open\" or \"new\", not {v}")), + }; + let text = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + Some(v) => Err(format!("file.importLightroom: `{key}` must be a string, not {v}")), + }; + Ok(Request { into, name: text("name")?, location: text("location")? }) + } +} + +/// What `file.importLightroom` does with a catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Plan { + /// Import into the library open now. + Merge, + /// Make a new library, open it, import into it. + NewLibrary, + /// Show the window; `new_library`: "Create a new library" is the choice it opens with. + Ask { new_library: bool }, +} + +/// The decision, from the setting, the open library (`on_disk`: saved in a folder, not in memory; +/// `photos`: how many it holds) and the command's `into`. `into` always wins; without it a library +/// in memory, or the setting "Always merge", merges; otherwise the window asks, opening on "Merge" +/// when the library is empty (there is nothing to merge with) and on "Create a new library" when +/// it holds photos. +pub fn decide(setting: LightroomImport, on_disk: bool, photos: usize, into: Option<Destination>) -> Plan { + match into { + Some(Destination::Open) => Plan::Merge, + Some(Destination::New) => Plan::NewLibrary, + None if !on_disk || setting == LightroomImport::AlwaysMerge => Plan::Merge, + None => Plan::Ask { new_library: photos > 0 }, + } +} + +/// The photos in the open library (not those in Recently Deleted or browsed under Local). +fn library_photos(app: &LightcraftApp) -> usize { + app.session.catalog.photos().filter(|p| p.in_library()).count() +} + +/// The open library's folder, when it is saved on disk. +fn library_dir(app: &LightcraftApp) -> Option<PathBuf> { + app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()) +} + +/// Where a new library goes by default: next to the open one (its parent folder). +pub fn default_location(app: &LightcraftApp) -> Option<PathBuf> { + library_dir(app).and_then(|d| d.parent().map(Path::to_path_buf)) +} + +/// The new library's default name: the catalog's file name without `.lrcat` (`Travel.lrcat` → +/// `Travel`), or `Travel 2`, `Travel 3`… when `location` has something by that name already. +pub fn default_name(catalog: &Path, location: Option<&Path>) -> String { + let stem = catalog.file_stem().map(|s| s.to_string_lossy().trim().to_string()).filter(|s| !s.is_empty()); + let stem = stem.unwrap_or_else(|| "Lightroom Catalog".to_string()); + match location { + Some(loc) => crate::panels::settings::free_name(loc, &stem).file_name().map_or(stem.clone(), |n| n.to_string_lossy().into_owned()), + None => stem, + } +} + +/// Refuse while a Lightroom catalog task or another import runs (as the import itself would), +/// before a window asks a question whose answer couldn't be carried out. +fn busy(app: &LightcraftApp) -> Result<(), String> { + if crate::lightroom_import::is_running(app) { + return Err("a Lightroom catalog task is already running; wait for it to finish".into()); + } + if app.import.is_some() || app.scan.is_some() { + return Err("an import is running; wait for it to finish before importing a Lightroom catalog".into()); + } + Ok(()) +} + +/// `file.importLightroom` once the catalog `path` is known: merge, make a new library first, or +/// ask, as [`decide`] says. Returns the import's start (`{running, kind, path}`), with +/// `library: {path, name, photos}` when a new library was made, or `{asking: true, path, into}` +/// while the window asks (`into`: the choice it opens with). +pub fn import(app: &mut LightcraftApp, path: &str, req: &Request) -> Result<Value, String> { + // the browser build can't import a catalog at all: the import says so (and no library is made) + if cfg!(target_arch = "wasm32") { + return app.run("library.importLightroom", json!({"path": path})); + } + let on_disk = library_dir(app).is_some(); + match decide(app.ui.settings.lightroom_import, on_disk, library_photos(app), req.into) { + Plan::Merge => app.run("library.importLightroom", json!({"path": path})), + Plan::NewLibrary => into_new_library(app, path, req), + Plan::Ask { new_library } => { + busy(app)?; + if !Path::new(path).is_file() { + return Err(format!("{path}: there is no Lightroom catalog file here")); + } + let location = req.location.clone().or_else(|| default_location(app).map(|d| d.to_string_lossy().into_owned())).unwrap_or_default(); + let name = req.name.clone().unwrap_or_else(|| default_name(Path::new(path), (!location.is_empty()).then_some(Path::new(&location)))); + app.ui.dialog = Some(Dialog::LightroomDestination { + catalog: path.to_string(), + library: crate::panels::settings::open_library_name(app).unwrap_or_default(), + photos: library_photos(app), + new_library, + name, + location, + dont_ask: false, + generation: app.session.library_generation(), + }); + Ok(json!({"asking": true, "path": path, "into": if new_library { "new" } else { "open" }})) + } + } +} + +/// Make the new library (`app.newLibrary {path: location, name}`: its rules, its errors), open it +/// and import the catalog there. The catalog is checked first, so a mistyped path doesn't leave an +/// empty library behind. +fn into_new_library(app: &mut LightcraftApp, path: &str, req: &Request) -> Result<Value, String> { + busy(app)?; + if !Path::new(path).is_file() { + return Err(format!("{path}: there is no Lightroom catalog file here")); + } + let location = match req.location.as_deref().map(str::trim) { + Some("") => return Err("file.importLightroom: choose where to put the new library (`location`)".into()), + Some(l) => PathBuf::from(l), + None => default_location(app).ok_or("file.importLightroom: no library is open on disk to put the new one next to; give `location`")?, + }; + if !location.is_absolute() { + return Err(format!("file.importLightroom: {}: the location must be a full folder path", location.display())); + } + let name = req.name.clone().unwrap_or_else(|| default_name(Path::new(path), Some(&location))); + let library = app.run("app.newLibrary", json!({"path": location.to_string_lossy(), "name": name}))?; + let mut started = app.run("library.importLightroom", json!({"path": path}))?; + if let Some(o) = started.as_object_mut() { + o.insert("library".into(), library); + } + Ok(started) +} + +/// The window's choice as `file.importLightroom` params. +fn params(catalog: &str, new_library: bool, name: &str, location: &str) -> Value { + if new_library { json!({"path": catalog, "into": "new", "name": name, "location": location}) } else { json!({"path": catalog, "into": "open"}) } +} + +/// The window's confirm button: import as chosen. "Don't ask again" is kept once a merge has +/// started (it only applies to merging). Another library opened while the window was up (by an +/// agent, the control channel) makes the window stale for both choices: nothing is merged into +/// it, and no new library is made from defaults that described the library it replaced. +pub fn confirm(app: &mut LightcraftApp, dialog: &Dialog) -> Result<Value, String> { + let Dialog::LightroomDestination { catalog, new_library, name, location, dont_ask, generation, .. } = dialog else { + return Err("not the Lightroom catalog destination window".into()); + }; + if *generation != app.session.library_generation() { + return Err(crate::i18n::tr("Another library was opened since this window appeared: nothing was imported").to_string()); + } + let r = app.run("file.importLightroom", params(catalog, *new_library, name, location))?; + if !*new_library && *dont_ask { + app.ui.settings.lightroom_import = LightroomImport::AlwaysMerge; + } + Ok(r) +} + +/// After a failed confirm the window stays open to be corrected (a folder that isn't empty, a +/// name a folder can't have), unless the library it named has gone. +pub fn keeps_open_on_error(app: &LightcraftApp, dialog: &Dialog) -> bool { + matches!(dialog, Dialog::LightroomDestination { generation, .. } if *generation == app.session.library_generation()) +} + +/// The confirm button's label. +pub fn ok_label(dialog: &Dialog) -> &'static str { + match dialog { + Dialog::LightroomDestination { new_library: true, .. } => "Create Library and Import", + _ => "Import", + } +} + +/// The window's body: the two choices, the new library's name and location, and "Don't ask again". +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) { + let Dialog::LightroomDestination { catalog, library, photos, new_library, name, location, dont_ask, .. } = dialog else { return }; + let t = crate::theme::Tokens::get(ui.ctx()); + let tr = crate::i18n::tr; + let file = Path::new(catalog.as_str()).file_name().map_or_else(|| catalog.clone(), |n| n.to_string_lossy().into_owned()); + ui.label(crate::i18n::tr_format!("Where should the Lightroom catalog “{catalog}” go?", catalog = file)); + ui.label(egui::RichText::new(catalog.as_str()).color(t.text_dim)); + ui.add_space(4.0); + let n = *photos; + let merge = crate::i18n::tr_format!( + "Merge into the open library “{library}” ({count} photo{})", + if n == 1 { "" } else { "s" }, + library = library, + count = n + ); + let r = ui.radio_value(new_library, false, merge); + crate::widgets::register(ui.ctx(), "radio:lightroomDestination-open", r.rect); + let indent = ui.spacing().icon_width + ui.spacing().icon_spacing; + let note = |ui: &mut egui::Ui, text: &str| { + ui.horizontal(|ui| { + ui.add_space(indent); + ui.add(egui::Label::new(egui::RichText::new(text).color(t.text_dim)).wrap()); + }); + }; + note( + ui, + if n == 0 { + tr("The library is empty: the catalog's photos, collections and edits become its first.") + } else { + tr("The catalog's photos, collections and edits join the ones already in this library.") + }, + ); + let r = ui.radio_value(new_library, true, tr("Create a new library")); + crate::widgets::register(ui.ctx(), "radio:lightroomDestination-new", r.rect); + ui.add_enabled_ui(*new_library, |ui| { + // a field's caption, left-aligned in a column as wide as the longer one (in any language) + let font = egui::TextStyle::Body.resolve(ui.style()); + let caption_w = [tr("Library name"), tr("Location")] + .iter() + .map(|c| ui.painter().layout_no_wrap(c.to_string(), font.clone(), t.text_label).size().x) + .fold(60.0_f32, f32::max) + + 8.0; + let caption = |ui: &mut egui::Ui, text: &str| { + ui.allocate_ui_with_layout(egui::vec2(caption_w, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(caption_w); + ui.label(egui::RichText::new(text).color(t.text_label)); + }); + }; + ui.horizontal(|ui| { + ui.add_space(indent); + caption(ui, tr("Library name")); + crate::text_field::TextField::singleline("field:lightroomLibraryName", name) + .label(tr("Library name")) + .width(ui.available_width().max(120.0)) + .show(ui); + }); + ui.horizontal(|ui| { + ui.add_space(indent); + caption(ui, tr("Location")); + let choose = app.services.pick_folder.is_some(); + let button_w = if choose { 80.0 } else { 0.0 }; + crate::text_field::TextField::singleline("field:lightroomLibraryLocation", location) + .label(tr("Location")) + .width((ui.available_width() - button_w).max(120.0)) + .show(ui); + if choose + && crate::widgets::text_button(ui, "lightroomLibraryChoose", tr("Choose…"), false).clicked() + && let Some(pick) = app.services.pick_folder.as_mut() + && let Some(d) = pick() + { + *location = d; + } + }); + note(ui, tr("LightCraft opens the new library, then imports the catalog into it. Its folder must be new or empty.")); + }); + ui.add_space(4.0); + // (it only speaks of merging: unticked and greyed out while a new library is chosen) + let mut ticked = *dont_ask && !*new_library; + let c = ui.add_enabled(!*new_library, egui::Checkbox::new(&mut ticked, tr("Don't ask again (always merge into the open library)"))); + crate::widgets::register(ui.ctx(), "check:lightroomDontAsk", c.rect); + if !*new_library { + *dont_ask = ticked; + } + ui.label(egui::RichText::new(tr("To be asked again: Settings → General → When importing a Lightroom catalog.")).size(11.0).color(t.text_dim)); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every combination of the setting, an empty or full library on disk or in memory, and `into`. + #[test] + fn the_setting_the_library_and_into_decide() { + use Destination::{New, Open}; + use LightroomImport::{AlwaysMerge, Ask}; + for setting in [Ask, AlwaysMerge] { + for on_disk in [true, false] { + for photos in [0, 5] { + // `into` always wins: the control channel acts without asking + assert_eq!(decide(setting, on_disk, photos, Some(Open)), Plan::Merge); + assert_eq!(decide(setting, on_disk, photos, Some(New)), Plan::NewLibrary); + } + } + } + // without it: ask, opening on Merge for an empty library and on a new library for a full one + assert_eq!(decide(Ask, true, 0, None), Plan::Ask { new_library: false }); + assert_eq!(decide(Ask, true, 5, None), Plan::Ask { new_library: true }); + // "Always merge" doesn't ask + assert_eq!(decide(AlwaysMerge, true, 0, None), Plan::Merge); + assert_eq!(decide(AlwaysMerge, true, 5, None), Plan::Merge); + // a library in memory has no folder to put a new one next to: it merges, as before + assert_eq!(decide(Ask, false, 0, None), Plan::Merge); + assert_eq!(decide(Ask, false, 5, None), Plan::Merge); + } + + /// `into`, `name` and `location` are read, and a wrong one is an error naming the command. + #[test] + fn params_are_checked_before_anything_happens() { + assert_eq!(Request::parse(&json!({})).unwrap(), Request::default()); + assert_eq!(Request::parse(&json!({"into": null})).unwrap(), Request::default()); + let r = Request::parse(&json!({"into": "new", "name": "Travel", "location": "/photos"})).unwrap(); + assert_eq!(r, Request { into: Some(Destination::New), name: Some("Travel".into()), location: Some("/photos".into()) }); + assert_eq!(Request::parse(&json!({"into": "open"})).unwrap().into, Some(Destination::Open)); + for bad in [json!({"into": "elsewhere"}), json!({"into": 1}), json!({"name": 3}), json!({"location": ["a"]})] { + let err = Request::parse(&bad).unwrap_err(); + assert!(err.starts_with("file.importLightroom:"), "{err}"); + } + } + + /// The catalog's file name, or the next free one in the location. + #[test] + fn the_new_library_is_named_after_the_catalog() { + assert_eq!(default_name(Path::new("/x/Travel.lrcat"), None), "Travel"); + assert_eq!(default_name(Path::new("/x/Lightroom Catalog-v13-3.lrcat"), None), "Lightroom Catalog-v13-3"); + // nothing to name it after + assert_eq!(default_name(Path::new(""), None), "Lightroom Catalog"); + // a name taken in the location: the next free one + let dir = std::env::temp_dir().join(format!("lc-ui-lrdest-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Travel")).unwrap(); + assert_eq!(default_name(Path::new("/x/Travel.lrcat"), Some(&dir)), "Travel 2"); + assert_eq!(default_name(Path::new("/x/Family.lrcat"), Some(&dir)), "Family"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 059a700e1..098c989d5 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -140,6 +140,22 @@ fn unsupported_wasm() -> Result<Value, String> { Err("Lightroom catalog import is unavailable in browser builds; open the catalog in the native LightCraft app".into()) } +/// Tests: a catalog import that never ends while the returned value lives, so a drop meets it pending. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn stall_import(app: &mut LightcraftApp) -> Box<dyn std::any::Any> { + let (tx, rx) = mpsc::channel::<Message>(); + let task = LightroomTask::new( + &app.session, + Kind::Import, + PathBuf::from("catalog.lrcat"), + Arc::new(AtomicBool::new(false)), + LightroomProgress::default(), + rx, + ); + app.lightroom = Some(task); + Box::new(tx) +} + /// Whether a Lightroom task currently owns the catalog transition. pub fn is_running(app: &LightcraftApp) -> bool { app.lightroom.is_some() diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 18b68b9d3..58c1648b0 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -48,6 +48,7 @@ pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a s if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) || (id == "app.openLibrary" && params.get("path").is_some()) + || (id == "app.recentLibraries" && params.get("remove").is_some()) { label } else { @@ -55,6 +56,67 @@ pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a s } } +/// Whether AppKit offers a shortcut to its menu instead of the window. Plain letters stay +/// with egui even when the native menu displays them as key equivalents. +pub fn native_menu_delivers(sc: &str) -> bool { + sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::<u8>().is_ok())) +} + +/// Native label and accelerator for a menu row. A grid row may borrow the cycle key as a +/// hint, but must not consume a modified key and turn cycling into direct grid selection. +/// AppKit has no separate shortcut-hint column: borrowed native-owned keys are appended to +/// the translated label instead, leaving their dispatch with egui. Ordinary G stays in the +/// native key-equivalent column because AppKit never hands it to the menu. +pub fn native_item_presentation<'a>( + keymap: &crate::shortcuts::Keymap, + id: &str, + params: &Value, + label: &str, + shortcut: Option<&'a str>, +) -> (String, Option<&'a str>) { + let label = display_item_label(id, params, label); + let borrowed = matches!(id, "view.photoGrid" | "view.squareGrid") && shortcut != crate::shortcuts::shortcut_of(keymap, id); + match shortcut.filter(|sc| borrowed && native_menu_delivers(sc)) { + Some(sc) => (format!("{label} ({})", shortcut_text(sc, true)), None), + None => (label.to_string(), shortcut), + } +} + +/// Cache key for native menu structure and effective accelerator ownership. A transfer from +/// a direct grid binding to a borrowed cycle hint must rebuild native handles and republish +/// their owned shortcuts even when the visible menu model is identical. +pub fn native_menu_structure(bar: &[(String, Vec<MenuNode>)], keymap: &crate::shortcuts::Keymap) -> String { + /// Add stable identities and effective native keys; live checked values stay outside the cache key. + fn walk(n: &[MenuNode], out: &mut String, keymap: &crate::shortcuts::Keymap) { + for x in n { + match x { + MenuNode::Item { id, params, label, checked, shortcut, .. } => { + out.push_str(&MenuNode::key(id, params)); + out.push(if checked.is_some() { 'c' } else { 'i' }); + out.push_str(shortcut.as_deref().unwrap_or("")); + // A key can change owners while both visible hints stay identical. + out.push('|'); + out.push_str(native_item_presentation(keymap, id, params, label, shortcut.as_deref()).1.unwrap_or("")); + } + MenuNode::Separator => out.push('-'), + MenuNode::Submenu { label, children } => { + out.push('['); + out.push_str(label); + walk(children, out, keymap); + out.push(']'); + } + } + out.push(';'); + } + } + let mut s = format!("{};", crate::i18n::language().code()); + for (t, items) in bar { + s.push_str(t); + walk(items, &mut s, keymap); + } + s +} + /// Top-level menus in order (the macOS app menu is added by the host). pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"]; @@ -342,6 +404,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> { "view.focusPoint" => Some(u.settings.focus_point), "view.zoomToFocus" => Some(u.settings.zoom_to_focus), "view.filmstrip" => Some(u.filmstrip), + "view.frameRate" => Some(u.frame_rate), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), "view.visualizeHdr" => Some(u.hdr_visualize), @@ -547,7 +610,7 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> item( "view.layoutImage", Value::Null, - "Show Layout Image", + "Layout Image", crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.layoutImage"), chosen, Some(li.show && chosen), @@ -633,23 +696,28 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> "Open Recent Library" => { let can = crate::panels::settings::can_switch_library(app).is_ok(); let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let mut v: Vec<MenuNode> = app - .ui - .settings - .recent_libraries + let recent = &app.ui.settings.recent_libraries; + let label = |r: &str| { + let d = std::path::Path::new(r); + format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()) + }; + let mut v: Vec<MenuNode> = recent .iter() .map(|r| { let d = std::path::Path::new(r); - // a missing library (moved, drive disconnected) is shown greyed out - let label = - format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()); - item("app.openLibrary", json!({"path": r}), label, None, can && d.is_dir(), Some(open.as_deref() == Some(d))) + // a library a check found missing (moved, drive disconnected) is greyed out; the + // check runs on a worker (`RecentChecks`), never here: the menu is rebuilt every frame + let gone = app.recent_checks.exists(r) == Some(false); + item("app.openLibrary", json!({"path": r}), label(r), None, can && !gone, Some(open.as_deref() == Some(d))) }) .collect(); if v.is_empty() { v.push(item("app.clearRecentLibraries", Value::Null, "No Recent Libraries", None, false, None)); } else { v.push(MenuNode::Separator); + // one entry at a time, missing ones too: only the list forgets it (issue #1290) + let forget = recent.iter().map(|r| item("app.recentLibraries", json!({"remove": r}), label(r), None, true, None)).collect(); + v.push(MenuNode::Submenu { label: "Remove from List".into(), children: forget }); v.push(item("app.clearRecentLibraries", Value::Null, "Clear Recent Libraries", None, true, None)); } v @@ -739,12 +807,14 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> }) } +/// Resolve a live menu row, including contextual keys and borrowed grid-cycle hints. fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { // Shift+P picks and advances in Library; don't advertise it on Presets in those views. let shortcut = if e.id == "panel.presets" && crate::shortcuts::library_grid(app) { None } else { e.shortcut.as_deref() }; // in an album the Delete key removes from the album (issue #1805): the menu says so let delete_key = crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "photo.delete"); let shortcut = match e.id.as_str() { + "view.photoGrid" | "view.squareGrid" => shortcut.or_else(|| crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.gridToggle")), "album.removePhotos" => delete_key, "photo.delete" if matches!(app.session.source, lightcraft_engine::LibrarySource::Album(_)) => None, _ => shortcut, @@ -863,9 +933,10 @@ pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result<Valu if id == "photo.delete" && crate::menus::confirm_delete(app) { return Ok(Value::Null); } + let was = app.session.active(); let r = app.run(id, params); if culling_cmd && r.is_ok() && app.ui.auto_advance { - crate::panels::compare::advance(app); + crate::panels::compare::advance(app, was); } r } @@ -1081,6 +1152,123 @@ mod tests { }) } + /// #2500: a direct-grid binding takes precedence over the borrowed cycle hint. + #[test] + fn grid_menu_shortcut_hints_preserve_direct_bindings_and_clicks() { + let mut a = app(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + let bar = menu_bar(&a); + let nodes = &bar.iter().find(|(title, _)| title == "View").unwrap().1; + assert!(matches!(find(nodes, "view.photoGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "Cmd+Shift+1")); + assert!(matches!(find(nodes, "view.squareGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "G")); + // A hint never assigns the cycle key to another command. + assert_eq!(crate::shortcuts::shortcut_of(&a.ui.settings.keymap, "view.squareGrid"), None); + run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid, "clicking the active row does not cycle"); + run_item(&mut a, "view.squareGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::SquareGrid); + run_item(&mut a, "view.gridToggle", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + } + + /// #2500: native hints never install a borrowed modifier/function key as a direct-grid + /// accelerator. Plain G remains in AppKit's shortcut column and is dispatched by egui. + #[test] + fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() { + let mut keymap = crate::shortcuts::Keymap::new(); + for id in ["view.photoGrid", "view.squareGrid"] { + let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some("G")); + assert_eq!(label, "Photo Grid"); + assert_eq!(accelerator, Some("G")); + assert!(!native_menu_delivers(accelerator.unwrap())); + for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { + crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap(); + let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key)); + assert_eq!(label, format!("Photo Grid ({text})")); + assert_eq!(accelerator, None, "egui keeps {key}"); + } + } + crate::shortcuts::assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + let (label, accelerator) = native_item_presentation(&keymap, "view.photoGrid", &Value::Null, "Photo Grid", Some("Cmd+Shift+1")); + assert_eq!(label, "Photo Grid"); + assert_eq!(accelerator, Some("Cmd+Shift+1"), "the row's own shortcut is a real accelerator"); + } + + /// #2500: the control channel runs the real keyboard path exactly once and menu commands + /// continue to select their named grid after the cycle shortcut is remapped. + #[test] + fn grid_menu_shortcut_hints_preserve_control_keyboard_dispatch() { + let mut h = crate::headless::Headless::new(app(), [960.0, 720.0], 1.0); + h.app.ui.view = ViewMode::PhotoGrid; + for _ in 0..3 { + h.step(); + } + let timeout = std::time::Duration::from_secs(20); + let press = |h: &mut crate::headless::Headless, params| { + let reply = h.request("ui.key", params, timeout); + assert_eq!(reply["ok"], true, "{reply}"); + }; + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + crate::shortcuts::assign(&mut h.app.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + for _ in 0..2 { + let reply = h.request("ui.menu.invoke", json!({"command": "view.squareGrid"}), timeout); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + } + } + + /// #2513 review: moving a direct-grid key to the cycle command leaves the visible model + /// unchanged, but must invalidate the native cache so its old accelerator is removed. + #[test] + fn grid_menu_shortcut_hints_invalidate_native_ownership_cache() { + let mut a = app(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+G")).unwrap(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.squareGrid", Some("Cmd+Shift+2")).unwrap(); + let before = menu_bar(&a); + let direct = native_menu_structure(&before, &a.ui.settings.keymap); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + let after = menu_bar(&a); + assert_eq!(before, after, "visible hint text does not expose the ownership change"); + assert_ne!(direct, native_menu_structure(&after, &a.ui.settings.keymap), "rebuild removes the stale direct-grid accelerator"); + } + + /// #2500: grid view rows show the separate cycle command's current shortcut. + #[test] + fn grid_menu_shortcut_hints_follow_the_cycle_key() { + let mut a = app(); + let hint = |a: &LightcraftApp, id: &str| { + let bar = menu_bar(a); + let nodes = &bar.iter().find(|(title, _)| title == "View").expect("View menu").1; + match find(nodes, id).expect("grid row") { + MenuNode::Item { shortcut, .. } => shortcut.clone(), + _ => panic!("grid row must be an item"), + } + }; + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("Cmd+Shift+G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id), None, "{id}"); + } + } + #[test] fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { let mut a = app(); @@ -1659,4 +1847,56 @@ mod tests { assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); assert_eq!(shortcut_text("Delete", true), "⌫"); } + + /// Every item of a menu, submenus included. + fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { + nodes + .iter() + .flat_map(|n| match n { + MenuNode::Submenu { children, .. } => flat(children), + MenuNode::Item { .. } => vec![n], + MenuNode::Separator => vec![], + }) + .collect() + } + + /// Given the menu bar, Then an item that turns something on or off is named for the thing, + /// with its check mark saying whether it is on: "Filmstrip ✓", never "Show Filmstrip ✓". + /// Show Original is a view (\), not a switch: it keeps Lightroom's name. + #[test] + fn toggles_are_named_for_the_thing_not_show() { + let mut a = app(); + a.ui.view = ViewMode::Detail; + let shows: Vec<String> = menu_bar(&a) + .iter() + .flat_map(|(_, items)| flat(items).into_iter().cloned().collect::<Vec<_>>()) + .filter_map(|n| match n { + MenuNode::Item { id, params, label, checked: Some(_), .. } + if params.is_null() && label.starts_with("Show ") && id != "view.showOriginal" => + { + Some(format!("{id}: {label}")) + } + _ => None, + }) + .collect(); + assert!(shows.is_empty(), "toggles named \"Show …\": {shows:#?}"); + } + + /// Given every command with a menu path, Then no two of them share a label, and none is named + /// like a menu: the menus, the keyboard shortcuts list and the control channel tell them apart + /// by name ("Detail" the view and "Detail" the Edit section were two things called the same). + #[test] + fn no_two_menu_commands_share_a_label() { + let entries = crate::menus::menu_entries(&app()); + let mut clashes = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if MENUS.contains(&e.label.as_str()) { + clashes.push(format!("{} is called like the {} menu", e.id, e.label)); + } + if let Some(other) = entries[..i].iter().find(|o| o.label == e.label && o.id != e.id) { + clashes.push(format!("{} and {} are both {:?}", other.id, e.id, e.label)); + } + } + assert!(clashes.is_empty(), "{clashes:#?}"); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5fde24d94..27378ad67 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -78,13 +78,13 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.gridToggle", "Grid", Some("G"), ""), ("library.selectOnlyActive", "Select Only Active Photo", Some("Cmd+Shift+D"), "Edit"), ("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"), - ("view.detail", "Detail", Some("D"), "View"), + ("view.detail", "Detail View", Some("D"), "View"), ("view.compare", "Compare", Some("Shift+C"), "View"), ("view.survey", "Survey", Some("N"), "View"), ("view.people", "People", None, "View"), ("view.person", "Show Person", None, ""), ("view.faceBoxes", "Face Boxes", None, "View"), - ("view.focusPoint", "Show Focus Point", Some("Alt+F"), "View"), + ("view.focusPoint", "Focus Point", Some("Alt+F"), "View"), ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), @@ -95,7 +95,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.filmstrip", "Filmstrip", Some("/"), "View"), ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), ("view.rightPanel", "Right Panel", None, "View"), - ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), + ("view.beforeAfter", "Before/After Side by Side", Some("Y"), "View"), ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), @@ -111,23 +111,26 @@ pub const UI_COMMANDS: &[UiCommand] = &[ // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), - ("view.clipping", "Show Clipping", Some("J"), "View"), + ("view.clipping", "Clipping", Some("J"), "View"), ("view.visualizeHdr", "Visualize HDR", None, "View"), // in grids S expands/collapses stacks (the engine command it shadows) ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), - ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + ("view.maskOverlay", "Mask Overlay", Some("O"), "View"), // Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one - ("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+Shift+O"), "View"), + ("view.maskOverlayAll", "All Mask Overlays", Some("Cmd+Shift+O"), "View"), // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), - ("view.maskPins", "Show Mask Pins", None, "View"), + ("view.maskPins", "Mask Pins", None, "View"), // {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next // mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool // Overlay and the Remove panel list the modes. ("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""), ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), + // A while the Crop panel is open: the aspect lock as a first-class row. Visualize Spots keeps + // the key outside the panel; the two are a contextual pair in shortcuts::handle + ("crop.lockAspect", "Lock Aspect Ratio", Some("A"), ""), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), ("view.back", "Back to Grid", Some("Escape"), ""), @@ -142,11 +145,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), ("view.viewOptions", "View Options…", Some("Cmd+J"), "View"), ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), - ("view.layoutImage", "Show Layout Image", Some("Cmd+Alt+O"), "View"), + ("view.layoutImage", "Layout Image", Some("Cmd+Alt+O"), "View"), ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), ("view.layoutImageMatte", "Layout Image Matte", None, ""), ("view.navigator", "Navigator", None, "View"), - ("panel.edit", "Edit", Some("E"), "Window"), + ("panel.edit", "Edit Panel", Some("E"), "Window"), ("panel.profiles", "Profile Browser", None, "Window"), ("panel.crop", "Crop & Rotate", Some("C"), "Window"), ("panel.remove", "Remove", Some("H"), "Window"), @@ -180,7 +183,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), ("dialog.smartAlbum", "New Smart Album…", None, "File"), - ("view.photoCounts", "Show Photo Counts", None, "View"), + ("view.photoCounts", "Photo Counts", None, "View"), ("view.sidebarSection", "Show Sidebar Section", None, ""), ("view.sidebarMoveSection", "Move Sidebar Section", None, ""), ("view.sidebarReset", "Reset Sidebar Sections", None, ""), @@ -227,6 +230,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), ("file.addFolder", "Import from Folder…", None, "File"), + // {path?, into?: open | new, name?, location?}: asks where the catalog goes unless `into` (or + // Settings) says (#2672, `lightroom_destination`) ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), ("file.addFromDevice", "Import from Device", None, ""), ("file.findMissing", "Find Missing Photos…", None, "File"), @@ -256,6 +261,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.about", "About LightCraft", None, "Help"), ("app.systemInfo", "System Info…", None, "Help"), ("app.openLogFolder", "Open Log Folder", None, "Help"), + // the frame-rate display (`frame_rate`): a debugging aid, never saved + ("view.frameRate", "Frame Rate", None, "Help>Debug"), ("app.whatsNew", "What's New", None, "Help"), ("dialog.cull", "Assisted Culling…", None, "Photo"), ("app.help", "LightCraft Help", Some("F1"), "Help"), @@ -840,6 +847,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.filmstrip = !app.ui.filmstrip; Ok(Value::Null) } + "view.frameRate" => { + // {show?: bool} (toggles when omitted) + app.ui.frame_rate = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.frame_rate); + Ok(json!({"show": app.ui.frame_rate})) + } "view.leftPanel" => { app.ui.left_panel = !app.ui.left_panel; Ok(Value::Null) @@ -1126,6 +1138,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } Ok(Value::Null) } + // A under the Crop panel: the aspect lock (the panel's button runs the same toggle) + "crop.lockAspect" => app.run("crop.aspect", json!({"aspect": "toggle"})), "view.cropOverlay" => { use crate::state::CropOverlay::*; app.ui.crop_overlay = match app.ui.crop_overlay { @@ -1395,7 +1409,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(Value::Null) } "dialog.captureTime" => { - let time = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.date().replace('T', " ")).unwrap_or_default(); + // a time that doesn't fully read isn't offered as the starting point (`10:0` would read as midnight) + let time = app + .session + .active() + .and_then(|id| app.session.catalog.photo(id)) + .map(|p| p.date()) + .filter(|d| lightcraft_catalog::dates::shift_iso(d, 0).is_some()) + .map(|d| d.replace('T', " ")) + .unwrap_or_default(); let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); app.ui.dialog = Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); @@ -1524,7 +1546,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re "photo.editInExternal" => edit_in(app, &ctx, p, false), "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), "dialog.cull" => { - app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); + let (burst_gap, similarity) = app.ui.cull_burst.unwrap_or((10.0, 0.93)); + app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true, burst_gap, similarity }); Ok(Value::Null) } "app.whatsNew" => { @@ -1615,6 +1638,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re import_into_album(app, p, r) } "file.importLightroom" => { + // where the catalog goes (#2672): checked before a file dialog opens + let req = match crate::lightroom_destination::Request::parse(p) { + Ok(req) => req, + Err(e) => return Some(Err(e)), + }; let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { Some(path) => Some(path), None => { @@ -1627,7 +1655,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } }; match path { - Some(path) => app.run("library.importLightroom", json!({"path":path})), + Some(path) => crate::lightroom_destination::import(app, &path, &req), None => Ok(Value::Null), } } @@ -2408,8 +2436,14 @@ fn show_in_folders(app: &mut LightcraftApp, id: Option<lightcraft_catalog::Photo /// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original) /// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the /// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, -/// so a missing editor leaves no copy behind. +/// so a missing editor leaves no copy behind. Once the copies exist, an editor that won't open them +/// is reported in the result's `failed` (and a toast), not as an error. fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result<Value, String> { + // every way in (menu, shortcut, context menu, the control channel) says why nothing opened + edit_in_inner(app, ctx, p, photocraft).inspect_err(|e| app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e))) +} + +fn edit_in_inner(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result<Value, String> { let settings = &app.ui.settings; let (file, configured) = if photocraft { let path = settings.photocraft_path.trim(); @@ -2418,11 +2452,8 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: (settings.external_file, settings.external_editor.clone()) }; let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); - if let Some(check) = app.services.check_editor.as_ref() - && let Err(e) = check(&editor) - { - app.toast(ctx, e.clone()); - return Err(e); + if let Some(check) = app.services.check_editor.as_ref() { + check(&editor)?; } let mut params = file.params(); if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { @@ -2452,9 +2483,18 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: } } } - match (failed, copies.as_slice()) { - (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), - (None, [(path, _)]) => { + let mut r = r; + if let Some(o) = r.as_object_mut() { + o.insert("failed".into(), json!(failed)); + } + if let Some(e) = failed { + // the copies are in the library already (one undo step): an error would invite a retry + // that makes another set, so the result says what failed and the toast says why + app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); + return Ok(r); + } + match copies.as_slice() { + [(path, _)] => { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index 61485a271..13aad67a6 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -104,6 +104,9 @@ pub struct MergeState { pub error: Option<String>, /// Options whose preview failed (not retried until they change). pub failed_options: Option<MergeDialog>, + /// The preview zoomed in by a click: the point of it (0..1 across and down) at the middle of + /// the box. `None` fits it in the box. Kept while the options change, to compare them there. + pub zoom: Option<(f32, f32)>, /// The last finished merge (`{id, path, …}`). pub last_result: Option<Value>, /// Photos the dialog merges (the selection when it opened). @@ -164,6 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { app.merge.error = None; app.merge.preview = None; app.merge.preview_pixels = None; + app.merge.zoom = None; if let Some(t) = app.merge.preview_task.take() { t.cancel.store(true, Ordering::Relaxed); } @@ -365,6 +369,56 @@ fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { app.merge.failed_options.as_ref() == Some(opts) } +/// Where a preview of `size` pixels is drawn in `area`: fitted, or zoomed in on the point `zoom` +/// to one preview pixel per screen pixel (at least twice the fitted size), kept over the box +/// where it covers it. Returns the rectangle and the point actually at the middle. +fn preview_rect(area: egui::Rect, size: [usize; 2], ppp: f32, zoom: Option<(f32, f32)>) -> (egui::Rect, Option<(f32, f32)>) { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let fit = (area.width() / w).min(area.height() / h); + let Some((u, v)) = zoom else { + return (egui::Rect::from_center_size(area.center(), egui::vec2(w * fit, h * fit)), None); + }; + let s = (1.0 / ppp.max(0.1)).max(fit * 2.0); + let img = egui::vec2(w * s, h * s); + // the point clicked to the middle, but no gap at an edge where the image covers the box + let axis = |t: f32, len: f32, lo: f32, centre: f32, span: f32| -> (f32, f32) { + if len <= span { + return (centre - len / 2.0, 0.5); + } + let start = (centre - t.clamp(0.0, 1.0) * len).clamp(lo + span - len, lo); + (start, (centre - start) / len) + }; + let (x, u) = axis(u, img.x, area.left(), area.center().x, area.width()); + let (y, v) = axis(v, img.y, area.top(), area.center().y, area.height()); + (egui::Rect::from_min_size(egui::pos2(x, y), img), Some((u, v))) +} + +/// Click on the preview: zoom in on the point clicked, or back out; drag pans while zoomed in. +fn preview_input(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img: egui::Rect) { + let zoomed = app.merge.zoom.is_some(); + if resp.hovered() { + let cursor = match (zoomed, resp.dragged()) { + (false, _) => egui::CursorIcon::ZoomIn, + (true, false) => egui::CursorIcon::Grab, + (true, true) => egui::CursorIcon::Grabbing, + }; + ui.ctx().set_cursor_icon(cursor); + } + if resp.clicked() { + app.merge.zoom = if zoomed { + None + } else { + resp.interact_pointer_pos() + .map(|q| (((q.x - img.left()) / img.width()).clamp(0.0, 1.0), ((q.y - img.top()) / img.height()).clamp(0.0, 1.0))) + }; + } else if resp.dragged() + && let Some((u, v)) = app.merge.zoom + { + let d = resp.drag_delta(); + app.merge.zoom = Some((u - d.x / img.width(), v - d.y / img.height())); + } +} + /// The dialog body (options on the left, preview on the right). pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) { let t = crate::theme::Tokens::get(ui.ctx()); @@ -372,13 +426,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) // preview ui.vertical(|ui| { let box_size = egui::vec2(520.0, 360.0); - let (rect, _) = ui.allocate_exact_size(box_size, egui::Sense::hover()); + let (rect, resp) = ui.allocate_exact_size(box_size, egui::Sense::click_and_drag()); ui.painter().rect_filled(rect, 4.0, t.canvas); crate::widgets::register(ui.ctx(), "merge.preview", rect); if let Some((tex, size, _, _)) = &app.merge.preview { - let s = (box_size.x / size[0] as f32).min(box_size.y / size[1] as f32); - let r = egui::Rect::from_center_size(rect.center(), egui::vec2(size[0] as f32 * s, size[1] as f32 * s)); - ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + let (tex, size) = (tex.id(), *size); + let (r, zoom) = preview_rect(rect, size, ui.ctx().pixels_per_point(), app.merge.zoom); + app.merge.zoom = zoom; + ui.painter_at(rect).image(tex, r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + preview_input(app, ui, &resp, r); } if let Some(task) = &app.merge.preview_task { let (f, stage) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); @@ -497,6 +553,46 @@ fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) - mod tests { use super::*; + fn area() -> egui::Rect { + egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(520.0, 360.0)) + } + + #[test] + fn the_preview_fits_until_clicked() { + let (r, zoom) = preview_rect(area(), [1024, 683], 1.0, None); + assert_eq!(zoom, None); + assert!((r.width() - 520.0).abs() < 0.5 && r.height() < 360.0 && (r.center() - area().center()).length() < 0.5, "{r:?}"); + } + + /// Like Lightroom's: a click zooms in to one preview pixel per screen pixel on the point clicked. + #[test] + fn a_click_zooms_in_on_the_point_clicked() { + // a panorama preview, wide enough that one pixel per pixel is more than twice the fitted size + let (r, zoom) = preview_rect(area(), [1600, 1000], 1.0, Some((0.4, 0.55))); + assert_eq!(r.size(), egui::vec2(1600.0, 1000.0)); + let (u, v) = zoom.unwrap(); + assert!((u - 0.4).abs() < 1e-4 && (v - 0.55).abs() < 1e-4, "{u} {v}"); + let at = r.min + egui::vec2(r.width() * 0.4, r.height() * 0.55); + assert!((at - area().center()).length() < 0.5, "the point clicked is in the middle: {at:?}"); + // on a Retina screen, one preview pixel per physical pixel, but always at least twice the fitted size + let (r, _) = preview_rect(area(), [1024, 683], 2.0, Some((0.5, 0.5))); + assert!((r.width() - 1040.0).abs() < 0.5, "{r:?}"); + } + + /// Zoomed in near a corner (or panned past it), the image still covers the box. + #[test] + fn a_zoomed_preview_stays_over_the_box() { + for p in [(0.0, 0.0), (1.0, 1.0), (-3.0, 7.0)] { + let (r, zoom) = preview_rect(area(), [1024, 683], 1.0, Some(p)); + assert!(r.contains_rect(area()), "{p:?}: {r:?}"); + let (u, v) = zoom.unwrap(); + assert!((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v), "{p:?}: {u} {v}"); + } + // a wide panorama is shorter than the box: centred up and down + let (r, _) = preview_rect(area(), [1024, 120], 1.0, Some((0.9, 0.0))); + assert!((r.center().y - area().center().y).abs() < 0.5 && r.left() < area().left() && r.right() >= area().right(), "{r:?}"); + } + /// Issue #1455: a panorama held to the computer's memory, or to the largest size, says so. #[test] fn a_smaller_panorama_says_why() { diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 32e079555..8bf9a8556 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -3,7 +3,7 @@ //! that can be cancelled. The tasks come from the engine's registry ([`lightcraft_engine::activity`]); ✕ runs //! `activity.cancel` like any other frontend would. -use egui::{Align, Layout, Margin, Rect, RichText, Sense, Stroke, pos2, vec2}; +use egui::{Align, Layout, Margin, RichText, Sense, Stroke, pos2, vec2}; use lightcraft_engine::activity::{TaskInfo, Unit}; use serde_json::json; @@ -136,7 +136,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { clicked = resp.on_hover_text(tr("Cancel")).clicked(); } }); - bar(ui, t, task, width); + bar(ui, task, width); let line = detail_line(task); if !line.is_empty() { ui.add(egui::Label::new(RichText::new(line).font(t.font(11.0)).color(t.text_dim)).truncate()); @@ -146,21 +146,11 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { clicked } -/// The progress bar; while the amount of work isn't known, a segment sweeps across. -fn bar(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) { - let (rect, _) = ui.allocate_exact_size(vec2(width, 5.0), Sense::hover()); - let p = ui.painter(); - p.rect_filled(rect, 2.5, t.field); - let fill = if task.total > 0 { - let frac = (task.done as f64 / task.total as f64).clamp(0.0, 1.0) as f32; - Rect::from_min_size(rect.min, vec2(rect.width() * frac, rect.height())) - } else { - let phase = (ui.input(|i| i.time) * 0.7).fract() as f32; - let w = rect.width() * 0.3; - let x = rect.left() + (rect.width() + w) * phase - w; - Rect::from_min_max(pos2(x.max(rect.left()), rect.top()), pos2((x + w).min(rect.right()), rect.bottom())) - }; - if fill.width() > 0.0 { - p.rect_filled(fill, 2.5, if task.cancelling { t.text_dim } else { t.accent }); - } +/// The progress bar, named after its task for screen readers; while the amount of work isn't +/// known, a segment sweeps across. +pub(crate) fn bar(ui: &mut egui::Ui, task: &TaskInfo, width: f32) -> egui::Response { + let fraction = (task.total > 0).then(|| (task.done as f64 / task.total as f64) as f32); + let response = crate::progress::Progress::new(fraction).width(width).dimmed(task.cancelling).show(ui); + crate::widgets::screen_reader_name(ui.ctx(), response.id, tr(&task.label)); + response } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 226ac9aa8..a0b9bf5c6 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -8,7 +8,7 @@ use crate::LightcraftApp; use crate::icons::{Icon, paint}; use crate::state::{ViewMode, Zoom}; use crate::theme::Tokens; -use crate::widgets::{icon_button, register, stars}; +use crate::widgets::{icon_button, register}; const VIEW_MODES: &[(&str, Icon, ViewMode, &str)] = &[ ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), @@ -105,7 +105,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); pill_ui.spacing_mut().item_spacing.x = 3.0; let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); - if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { + if let Some(r) = crate::rating::Rating::new("bottom", rating).show(&mut pill_ui) { let mut params = json!({"rating": r}); crate::panels::compare::target_active(app, &mut params); let _ = app.run("photo.rate", params); diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index e66244447..66442a975 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -41,10 +41,16 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt return format!("{}: {}", tr("Rules"), rules_label(rules, catalog)); } if chip.clear.get("label").is_some() { - let mut labels = filter.labels.clone(); - labels.extend(filter.label.filter(|label| !labels.contains(label))); - let names = labels.iter().map(|label| crate::i18n::color_label(catalog, *label)).collect::<Vec<_>>(); - return format!("{}: {}", tr("Label"), names.join(&format!(" {} ", tr("or")))); + let shown = filter.labels_shown().unwrap_or_default(); + let name = |label: &lightcraft_catalog::ColorLabel| crate::i18n::color_label(catalog, *label); + let or = |labels: &[lightcraft_catalog::ColorLabel]| labels.iter().map(name).collect::<Vec<_>>().join(&format!(" {} ", tr("or"))); + let names = if shown.is_empty() { + let plural = if filter.labels.len() > 1 { format!("({})", or(&filter.labels)) } else { or(&filter.labels) }; + format!("{} {} {plural}", filter.label.as_ref().map(name).unwrap_or_default(), tr("and")) + } else { + or(&shown) + }; + return format!("{}: {names}", tr("Label")); } if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() { return match (&filter.date_from, &filter.date_to) { diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 2280c2241..5b9bb8185 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -164,11 +164,13 @@ pub fn survey_step(app: &mut LightcraftApp, d: isize) -> Result<Value, String> { } /// After rating/flagging with Auto Advance: next candidate (Compare), next photo in the survey, -/// else the next photo in the view. -pub fn advance(app: &mut LightcraftApp) { +/// else the next photo in the view. `was` is the active photo before the rating: a rating that took +/// it out of the view made the photo after it active already, and that is the next one (#2312). +pub fn advance(app: &mut LightcraftApp, was: Option<PhotoId>) { let _ = match app.ui.view { ViewMode::Compare => compare_step(app, 1), ViewMode::Survey => survey_step(app, 1), + _ if app.session.active() != was => Ok(Value::Null), _ => app.session.execute("library.next", &json!({})).map_err(|e| e.to_string()), }; } @@ -279,7 +281,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan); p.image(tex.tex.id(), display, uv, Color32::WHITE); } else { - p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38)); + p.rect_filled(img.intersect(img_area), 0.0, Tokens::get(ui.ctx()).skeleton); } if photo.flag == Flag::Reject { p.rect_filled(display, 0.0, Color32::from_black_alpha(110)); @@ -343,7 +345,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let filled = i < photo.rating; let r = Rect::from_min_size(pos2(x + i as f32 * 13.0, cap.center().y - 6.0), vec2(12.0, 12.0)); if r.right() < cap.right() { - paint(pt, r, if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); + crate::rating::paint_star(pt, r, filled, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); } } x += 5.0 * 13.0 + 4.0; diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index 9a0385639..d070f5b74 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -153,6 +153,23 @@ pub fn detail_toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, on }); } +/// The bar under the Denoise slider while a photo's picture is made: at most 240 px wide, and +/// sweeping while the number of tiles is not known yet. The percentage is `progress::percent`, as the +/// bar's accessible value is: 100 % only once done. +pub(crate) fn detail_bar(ui: &mut egui::Ui, done: u64, total: u64) -> egui::Response { + let fraction = crate::progress::fraction_of(done, total); + let text = match fraction { + Some(f) => tr_format!("Making the denoised picture… {}%", crate::progress::percent(f)), + None => tr("Starting…").to_string(), + }; + crate::progress::Progress::new(fraction).label(text).width(ui.available_width().min(240.0)).widget("denoise:progress").show(ui) +} + +/// The bar in Settings, AI Denoise: 380 px wide however wide the tab is. +pub(crate) fn settings_bar(ui: &mut egui::Ui, done: u64, total: u64, text: String) -> egui::Response { + crate::progress::Progress::new(crate::progress::fraction_of(done, total)).label(text).width(380.0).widget("denoise:settingsProgress").show(ui) +} + /// The line under the Denoise slider for the photo `id`, whose Amount is `amount`. pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, amount: f64) { let t = Tokens::get(ui.ctx()); @@ -180,7 +197,12 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am let now = app.tick_now(); app.caches.denoise.next_pump = app.caches.denoise.next_pump.min(now + 0.05); ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); - egui::Frame::NONE.inner_margin(pad).show(ui, |ui| note(ui, "Waiting to start…", t.text_dim)); + egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + note(ui, "Waiting to start…", t.text_dim); + }) + }); } else if amount > 0.0 { egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { note(ui, "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", t.text_dim); @@ -205,14 +227,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am } PhotoState::Running { done, total } => { egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { - let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; - let text = if total > 0 { - tr_format!("Making the denoised picture… {}%", (frac * 100.0).round() as u32) - } else { - tr("Starting…").to_string() - }; - let bar = ui.add(egui::ProgressBar::new(frac).desired_width(ui.available_width().min(240.0)).text(RichText::new(text).size(11.0))); - register(ui.ctx(), "denoise:progress", bar.rect); + detail_bar(ui, done as u64, total as u64); }); } PhotoState::Failed { why, unsupported } => { @@ -257,8 +272,12 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { .map(|v| v["downloads"].as_array().cloned().unwrap_or_default()) .unwrap_or_default(); heading(ui, t, "Models"); - let r = ui.add_enabled(runtime && app.services.pick_denoise_model.is_some(), egui::Button::new(tr("Install from file…"))); - register(ui.ctx(), "denoise:installFile", r.rect); + let installing = downloads.iter().any(|d| d["id"] == "local-install" && d["state"].as_str() == Some("installing")); + let r = crate::button::Button::new("Install from file…") + .enabled(runtime && app.services.pick_denoise_model.is_some()) + .busy(installing) + .widget("denoise:installFile") + .show(ui); if r.clicked() && let Some(path) = app.services.pick_denoise_model.as_mut().and_then(|f| f().into_iter().next()) { @@ -333,7 +352,6 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued); match running { Some((done, total)) => { - let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let name = status["running"]["photo"] .as_u64() .and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())) @@ -345,8 +363,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V done = done, total = total ); - let bar = ui.add(egui::ProgressBar::new(frac).desired_width(380.0).text(RichText::new(text).size(11.5))); - register(ui.ctx(), "denoise:settingsProgress", bar.rect); + settings_bar(ui, done, total, text); } None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), None => { @@ -451,17 +468,22 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, match (dl_state, dl) { (Some("running"), Some(d)) => { let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0)); - let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let text = if total > 0 && got >= total { tr("Checking…").to_string() } else { tr_format!("{} of {} from {host}", mb(Some(got)), mb(Some(total)), host = host) }; - let bar = ui.add(egui::ProgressBar::new(frac).desired_width(260.0).text(RichText::new(text).font(t.font(11.5)))); - register(ui.ctx(), format!("denoise:progress:{id}"), bar.rect); + crate::progress::Progress::new(crate::progress::fraction_of(got, total)) + .label(text) + .width(crate::progress::ROW_WIDTH.min(ui.available_width())) + .widget(format!("denoise:progress:{id}")) + .show(ui); } (Some("done" | "installing"), _) => { - ui.label(RichText::new(tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + ui.label(RichText::new(tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); + }); } (Some("failed"), Some(d)) => { let why = sentence(d["error"].as_str().unwrap_or("The download failed")); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 4d050235e..30086369b 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -76,6 +76,13 @@ pub enum Gesture { at: Point, acc: f32, }, + /// A drag of one of the histogram's tonal zones (see [`super::histogram`]): the control the + /// press locked and the unrounded value its sideways travel has reached. It holds no canvas + /// geometry: the zone stays locked wherever the pointer goes. + HistogramDrag { + control: &'static str, + value: f64, + }, } /// Screen ↔ normalized-image mapping for the displayed frame. @@ -187,6 +194,20 @@ pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: e let _ = app.run("view.navigate", json!({"pan": pan})); } +/// A drag with the middle button (the mouse wheel pressed) pans the zoomed photo whatever tool is +/// active, so the healing brush, mask brushes and crop never paint or move while you look around; +/// the hand cursor shows while it does. +fn middle_pan(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, area: Rect, img: Rect) -> bool { + if !resp.dragged_by(egui::PointerButton::Middle) { + return false; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if img.width() > area.width() + 1.0 || img.height() > area.height() + 1.0 { + pan_image(app, area, img, resp.drag_delta()); + } + true +} + /// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad. fn wheel_notches(events: &[egui::Event]) -> f32 { events @@ -306,7 +327,7 @@ pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) } -/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Show Focus Point): green corner +/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner /// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. /// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:<id>`). pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { @@ -340,13 +361,16 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } -/// Whether an overlay can be rendered on a region tile without changing what the tile means. -/// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask -/// alphas as the full render; the other diagnostic overlays either need whole-frame context or -/// deliberately replace the loupe. +/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) +/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize +/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high +/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin +/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels. pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { - use lightcraft_pipeline::Overlay; - matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. }) + use lightcraft_pipeline::Overlay as O; + match overlay { + O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, + } } /// What a window render of the loupe needs to know about the view it belongs to. @@ -532,7 +556,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut navigating = false; for &view_area in &areas { let view_img = in_pane(view_area, img_rect); - if navigate_gesture(app, ui, &resp, view_area, view_img, native) { + if navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) { navigating = true; target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); img_rect = target_rect; @@ -548,7 +572,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; // `ready` first: `available` would wait for a device still being created) let gpu = interacting - && lightcraft_engine::gpu::handles(lightcraft_pipeline::OutputDepth::U8, app.ui.soft_proof, overlay) + && lightcraft_engine::gpu::handles( + lightcraft_pipeline::OutputDepth::U8, + app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), + overlay, + ) && lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); @@ -988,7 +1016,7 @@ fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) - fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { let Some(lines) = info_overlay_lines(app, photo) else { return }; let t = Tokens::get(p.ctx()); - let mut y = canvas.top() + INFO_OVERLAY_TOP; + let mut y = canvas.top() + info_overlay_top(app, &t); for (i, l) in lines.iter().enumerate() { // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white // layout made the shadow a second white copy (#1690) @@ -999,10 +1027,12 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li p.galley(at, g.clone(), Color32::WHITE); y += g.size().y + INFO_OVERLAY_GAP; } + y = overlay_badges(p, &t, pos2(canvas.left() + 14.0, y), photo); register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); } -/// Where the info overlay's text starts below the canvas' top, and the gap after each line. +/// Where the info overlay's text starts below the canvas' top (without window controls over it), +/// and the gap after each line. const INFO_OVERLAY_TOP: f32 = 12.0; const INFO_OVERLAY_GAP: f32 = 3.0; @@ -1011,14 +1041,22 @@ fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId { if i == 0 { t.semibold(15.0) } else { t.font(12.5) } } -/// How far below the canvas' top the info overlay's text reaches (0 when it is off), so a label -/// drawn in that corner can sit below it. +/// How far below the canvas' top the info overlay's text and badges reach (0 when it is off), so a +/// label drawn in that corner can sit below it. fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 { let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 }; let t = Tokens::get(p.ctx()); - lines.iter().enumerate().fold(INFO_OVERLAY_TOP, |y, (i, l)| { + let text = lines.iter().enumerate().fold(info_overlay_top(app, &t), |y, (i, l)| { y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP - }) + }); + text + if has_overlay_badges(photo) { OVERLAY_BADGES_H } else { 0.0 } +} + +/// Where the info overlay's text starts below the canvas' top: the full-screen preview has no top +/// bar, so on macOS it starts below the window controls that sit over the photo there (#2460). +fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 { + let controls = app.ui.fullscreen && app.integrated_titlebar && !app.window_is_fullscreen; + if controls { t.top_bar_h } else { INFO_OVERLAY_TOP } } /// The info overlay's lines for `photo`, or `None` when it is off. @@ -1036,6 +1074,45 @@ fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Some(lines) } +/// The height of the info overlay's rating and flag row. +const OVERLAY_BADGES_H: f32 = 17.0; + +fn has_overlay_badges(photo: &lightcraft_catalog::Photo) -> bool { + photo.rating > 0 || photo.flag != lightcraft_catalog::Flag::None +} + +/// The star rating and flag under the info overlay's lines, when the photo has either; returns +/// the y below them. +fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_catalog::Photo) -> f32 { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let flag = match photo.flag { + Flag::Pick => Some((Icon::FlagPickFilled, Color32::WHITE)), + Flag::Reject => Some((Icon::FlagReject, t.reject)), + Flag::None => None, + }; + if !has_overlay_badges(photo) { + return at.y; + } + let stars = photo.rating.min(5); + let mut icons: Vec<(Icon, Color32)> = vec![(Icon::StarFilled, Color32::WHITE); stars as usize]; + icons.extend(flag); + let side = 12.0; + let mut x = at.x; + for (i, (icon, color)) in icons.into_iter().enumerate() { + // the flag stands a little apart from the stars + if i > 0 && i == stars as usize { + x += 5.0; + } + let r = Rect::from_min_size(pos2(x, at.y + 1.0), vec2(side, side)); + paint(p, r.translate(vec2(1.0, 1.0)), icon, Color32::from_black_alpha(200)); + paint(p, r, icon, color); + x += side + 2.0; + } + register(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0))); + at.y + OVERLAY_BADGES_H +} + /// What the user did to a face box this frame. enum RegionEdit { /// The × was clicked. @@ -1323,7 +1400,7 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190)); match tex { Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE), - None => p.rect_filled(frame, 0.0, Color32::from_gray(40)), + None => p.rect_filled(frame, 0.0, Tokens::get(ui.ctx()).skeleton), }; // the visible part of the photo, in normalized image coordinates let vis = canvas.intersect(img); @@ -2847,7 +2924,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { continue; } let resp = ui.interact(cr, egui::Id::unique(("film", id.0)), Sense::click()); - crate::widgets::announce(&resp, app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default()); + let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some(); + crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed)); register(ui.ctx(), format!("film:{}", id.0), cr); let state = app.session.selection.state_of(*id); let sel = state == lightcraft_engine::SelectionState::Active; @@ -2870,7 +2948,14 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0)); super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8); - if let Some(tex) = app.renderer.thumb(*id) { + let thumb = app.renderer.thumb(*id); + if thumb.is_none() { + p.rect_filled(img_area, 0.0, t.skeleton); + if failed { + p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text); + } + } + if let Some(tex) = thumb { let [tw, th] = tex.size; let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32); let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s)); @@ -2938,6 +3023,17 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { }); } +/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture +/// when the thumbnail failed (the painted `!` has no name of its own). The name is empty when the +/// catalog no longer has the photo (or its file name is blank); the reason then stands alone. +pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String { + match (file_name.is_empty(), failed) { + (_, false) => file_name.to_string(), + (true, true) => crate::i18n::tr("This photo can't be opened").to_string(), + (false, true) => format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")), + } +} + /// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { use crate::icons::{Icon, paint}; @@ -2950,10 +3046,7 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); let y = bar.center().y; let mut x = bar.left() + 3.0; - for i in 0..ph.rating { - paint(p, Rect::from_min_size(pos2(x + i as f32 * 9.0, y - 4.0), vec2(8.0, 8.0)), Icon::StarFilled, t.star); - } - x += ph.rating as f32 * 9.0 + 2.0; + x += crate::rating::paint_rating(p, pos2(x, y), ph.rating, crate::rating::RatingSize::Small, t.star) + 2.0; match ph.flag { Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), @@ -3473,4 +3566,83 @@ mod info_overlay_tests { app.ui.info_overlay = InfoOverlay::Basic; assert_eq!(painted_lines(&app, &photo), Vec::<String>::new(), "nothing chosen, nothing shown"); } + + fn demo_app() -> (LightcraftApp, lightcraft_catalog::Photo) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + (app, photo) + } + + /// The overlay's text shapes and the rects it registered, painted on a canvas at the window's corner. + fn paint_overlay(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); + out.textures_delta.clear(); + fn texts(shape: &egui::epaint::Shape, out: &mut Vec<egui::epaint::TextShape>) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + (all, crate::widgets::take_registry(&ctx)) + } + + fn name_top(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> f32 { + let (texts, _) = paint_overlay(app, photo); + texts.iter().filter(|t| t.galley.job.text == photo.file_name).map(|t| t.pos.y).fold(f32::INFINITY, f32::min) + } + + /// #2460: in the full-screen preview on macOS the window controls stay at the top-left, so the + /// file name starts below them; in macOS full screen they are gone and it keeps the corner. + #[test] + fn the_file_name_clears_the_macos_window_controls_in_the_full_screen_preview() { + let (mut app, photo) = demo_app(); + app.ui.fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "no window controls over the photo"); + app.integrated_titlebar = true; + let bar = Tokens::get(&egui::Context::default()).top_bar_h; + assert!(name_top(&app, &photo) >= bar, "below the window controls"); + app.window_is_fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "macOS full screen hides the window controls"); + app.window_is_fullscreen = false; + app.ui.fullscreen = false; + assert_eq!(name_top(&app, &photo), 12.0, "the loupe's canvas starts below the top bar"); + } + + /// #2460: a rated or flagged photo shows its stars and flag under the date and size. + #[test] + fn the_info_overlay_shows_the_rating_and_flag_under_the_date() { + let (_, mut photo) = demo_app(); + let (app, _) = demo_app(); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::None; + let badges = |photo: &lightcraft_catalog::Photo| { + paint_overlay(&app, photo).1.into_iter().find(|(id, _)| id == "canvas:infoOverlayBadges").map(|(_, r)| r) + }; + assert_eq!(badges(&photo), None, "nothing to show"); + photo.rating = 3; + let stars = badges(&photo).expect("three stars"); + photo.flag = lightcraft_catalog::Flag::Pick; + let flagged = badges(&photo).expect("three stars and a flag"); + assert!(flagged.width() > stars.width(), "the flag follows the stars: {stars:?} {flagged:?}"); + let (texts, _) = paint_overlay(&app, &photo); + let date_bottom = texts.iter().map(|t| t.pos.y + t.galley.size().y).fold(0.0, f32::max); + assert!(flagged.top() >= date_bottom, "under the date and size: {flagged:?}, text ends at {date_bottom}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut extent = 0.0; + ctx.run_ui(raw, |ui| extent = info_overlay_extent(&app, ui.painter(), &photo)).textures_delta.clear(); + assert!(extent >= flagged.bottom(), "a label below the overlay clears the badges: {extent} < {flagged:?}"); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::Reject; + assert!(badges(&photo).is_some(), "a rejected photo shows its flag alone"); + } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 9527ab8c2..19e87d6c4 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -133,7 +133,14 @@ fn watermark_preview( Ok(img) => img, Err(e) => { let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" }; - let r = ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + let r = ui + .horizontal(|ui| { + if e.is_none() { + crate::progress::spinner(ui, 12.0); + } + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)) + }) + .inner; if let Some(e) = e { r.on_hover_text(e); } @@ -261,6 +268,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::MovePhotos { .. } => "Move Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", + Dialog::LightroomDestination { .. } => "Import Lightroom Catalog", Dialog::SaveEffect { .. } => "New Effect Preset", Dialog::ManageEffects { .. } => "Manage Effect Presets", Dialog::DeleteEffects { names } if names.len() == 1 => "Delete Effect Preset", @@ -300,6 +308,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let widest = if settings { ctx.data(|d| d.get_temp::<f32>(widest_id)).unwrap_or(0.0).min(screen.width() * 0.9) } else { 0.0 }; let mut content_w = 0.0f32; let mut body_h = None; + // before the window: drawing it closes a dropdown the click lands outside of + let popup_was_open = egui::Popup::is_any_open(ctx); let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) .resizable(import) @@ -315,6 +325,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Print => 1100.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, + Dialog::LightroomDestination { .. } => 480.0, Dialog::ManageEffects { .. } => 560.0, _ => 380.0, }) @@ -328,7 +339,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .logarithmic(true) .smallest_positive(1.0) .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()), + .custom_parser(crate::panels::dialogs::parse_gap), ); crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); @@ -337,7 +348,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::RichText::new(crate::i18n::tr_format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"], scope = crate::i18n::tr(scope))).color(t.text_dim), ); } - Dialog::Cull { reject_below, pick_best } => { + Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { let n = app.session.selection.ids.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(crate::i18n::tr_format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", scope = scope)).color(t.text_label)); @@ -347,6 +358,31 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr(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, crate::i18n::tr("Pick the sharpest photo of each burst")); crate::widgets::register(ui.ctx(), "check:cullPick", r.rect); + ui.add_space(6.0); + // the sliders drag over the usual range; their value fields take whatever + // `photo.analyze` accepts (a gap of 0 s to a day, a similarity of 0 to 1) + let r = ui.add( + egui::Slider::new(burst_gap, 1.0..=600.0) + .logarithmic(true) + .smallest_positive(1.0) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: shots at most this far apart")) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(crate::panels::dialogs::parse_gap), + ); + crate::widgets::register(ui.ctx(), "field:cullGap", r.rect); + let r = ui.add( + egui::Slider::new(similarity, 0.70..=0.99) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: how alike (loose … strict)")) + // a typed 0.995 is kept (whole hundredths would round it to 1.00) + .step_by(0.001) + .min_decimals(2) + .max_decimals(3), + ); + crate::widgets::register(ui.ctx(), "field:cullSimilar", r.rect); + clamp_cull_burst(burst_gap, similarity); + ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).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 => { @@ -523,10 +559,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { "shift" => params["shift"].as_i64(), _ => Some((*zone as f64 * 3600.0).round() as i64), }; - let after = delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)); + // the photo's own time is the problem (a Lightroom catalog's capture time + // is copied verbatim), not what was typed + let unreadable = lightcraft_catalog::dates::shift_iso(&cur, 0).is_none(); + let after = match (mode.as_str(), unreadable && n == 1) { + // alone, it simply gets the time typed (photo.setCaptureTime repairs it) + ("set", true) => lightcraft_catalog::dates::normalize_iso(time), + _ => delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)), + }; ui.label( egui::RichText::new(match after { Some(a) => format!("{name}: {} → {}", cur.replace('T', " "), a.replace('T', " ")), + None if unreadable && !(mode == "set" && n == 1) => crate::i18n::tr("The active photo's capture time can't be read").to_string(), None => "Enter a date as YYYY-MM-DD HH:MM:SS".into(), }) .color(t.text_label), @@ -611,7 +655,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); } None => { - ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim)); + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim)); + }); } } let more = total.saturating_sub(RENAME_PREVIEW_ROWS); @@ -1086,7 +1133,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::Merge { opts } => crate::merge::body(app, ui, opts), Dialog::Import { opts } => crate::import::body(app, ui, opts), - Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab), + // (Settings is never taller than `body_max`, so the scroll area round it never + // scrolls: its tab bar stays put and its pages scroll by themselves) + Dialog::Settings { tab } => crate::panels::settings::window(app, ui, tab, body_max), Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()), Dialog::KeywordTag { editing, @@ -1246,13 +1295,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::LightroomCatalog { folder, catalog } => { ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); ui.label(egui::RichText::new(catalog.as_deref().unwrap_or(folder.as_str())).color(t.text_dim)); - ui.label( - egui::RichText::new(crate::i18n::tr( - "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.", - )) - .color(t.text_dim), - ); + // (the import then asks where the catalog goes, unless Settings says to merge, #2672) + let asks = app.ui.settings.lightroom_import == crate::state::LightroomImport::Ask + && app.session.library.as_ref().is_some_and(|l| l.on_disk); + let what = if asks { + "The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library." + } else { + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now." + }; + ui.label(egui::RichText::new(crate::i18n::tr(what)).color(t.text_dim)); } + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::body(app, ui, d), Dialog::RemoveFolder { name, count, path, disk } => { ui.label(crate::i18n::tr_format!( "Remove “{name}” and its {count} photo{} from the library?", @@ -1370,6 +1423,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d), Dialog::SynchronizeFolder { .. } if informational => "Close", Dialog::SynchronizeFolder { .. } => "Synchronize", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", @@ -1439,6 +1493,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if settings && content_w > widest + 0.5 { ctx.data_mut(|d| d.insert_temp(widest_id, content_w)); } + if let Some(w) = &shown { + // (the import review keeps its hand-picked photos however the click lands) + if app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { + close = true; + } + } if let Some(w) = shown { ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); if let Some(body_h) = body_h { @@ -1459,7 +1519,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if confirm { match confirm_dialog(app, &dlg) { // the import review stays open on an error (e.g. an unusable folder template), and so - // does Export (e.g. no folder chosen) so the choices aren't lost + // does Export (e.g. no folder chosen) so the choices aren't lost, and Edit Capture Time + // (a time that isn't a date, or a shift it refuses) Err(e) if matches!( dlg, @@ -1475,6 +1536,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::KeywordSet { .. } | Dialog::SynchronizeFolder { .. } | Dialog::SaveEffect { .. } + | Dialog::CaptureTime { .. } ) => { app.toast(ctx, e) @@ -1502,6 +1564,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, e); close = true; } + // photos whose capture time doesn't read are left as they were: say so + Ok(r) if matches!(dlg, Dialog::CaptureTime { .. }) && r["unreadable"].as_u64().is_some_and(|n| n > 0) => { + app.toast(ctx, crate::i18n::tr("Photos whose capture time can't be read were left as they were")); + close = true; + } + // where a Lightroom catalog goes: stays open to be corrected (a folder that isn't + // empty), unless the library it names has gone + Err(e) if matches!(dlg, Dialog::LightroomDestination { .. }) => { + close = !crate::lightroom_destination::keeps_open_on_error(app, &dlg); + app.toast(ctx, e); + } Ok(_) if keeps_open(app, &dlg) => { if let Dialog::SamModel { error, .. } = &mut dlg { *error = None; @@ -1514,9 +1587,50 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { return; } + if !close { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(BACKDROP_SHOWN), frame)); + } app.ui.dialog = if close { None } else { Some(dlg) }; } +/// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`]. +const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; + +/// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and +/// released outside the window, so a slider dragged past its edge doesn't close it; a press while a +/// popup is open (a dropdown, a date picker's calendar) only closes the popup: `popup_was_open` is +/// read before the window is drawn, since drawing it already closes the popup. Only a press made +/// while the dialog is shown counts: in the frame a dialog appears, its input is the click that +/// opened it (a quick click on a slow frame presses and releases in that one frame), so it never +/// closes it. (Read from the raw events: egui forgets the press origin on release.) +fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_was_open: bool) -> bool { + let press_id = egui::Id::unique("dialog-backdrop-press"); + let frame = ctx.cumulative_frame_nr(); + // the same frame too: a second pass of it gets no input + let shown = ctx.data(|d| d.get_temp::<u64>(egui::Id::unique(BACKDROP_SHOWN))).is_some_and(|f| f + 1 >= frame); + if !shown { + ctx.data_mut(|d| d.remove_temp::<bool>(press_id)); + return false; + } + let (press, release, clicked) = ctx.input(|i| { + let at = |down: bool| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } if *pressed == down => Some(*pos), + _ => None, + }) + }; + (at(true), at(false), i.pointer.primary_clicked()) + }); + if let Some(p) = press { + let outside = !window.contains(p) && !popup_was_open; + ctx.data_mut(|d| d.insert_temp(press_id, outside)); + } + let Some(release) = release else { return false }; + let pressed_outside = ctx.data_mut(|d| d.remove_temp::<bool>(press_id)).unwrap_or(false); + clicked && pressed_outside && !window.contains(release) +} + /// Room kept free above and below a dialog (#781). const DIALOG_SCREEN_MARGIN: f32 = 12.0; /// A dialog's title bar, frame and button row before the first frame measures them. @@ -1578,6 +1692,61 @@ fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: i } /// Apply a dialog's action (also used by `ui.dialog.confirm`). +/// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` +/// (units `s`, `min`, `h`, `day`, each optional after a plain number of seconds). +pub fn parse_gap(text: &str) -> Option<f64> { + // the one translated text `fmt_gap` writes ("1 Tag") + if text.trim() == crate::i18n::tr("1 day") { + return Some(86400.0); + } + let mut total = 0.0; + let mut seen = false; + let mut words = text.split_whitespace().peekable(); + while let Some(word) = words.next() { + // "90s" as well as "90 s" + let split = word.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(word.len()); + let (num, unit) = word.split_at(split); + let n: f64 = num.parse().ok().filter(|n: &f64| n.is_finite())?; + let unit = if unit.is_empty() { + match words.peek() { + Some(u) if u.parse::<f64>().is_err() => words.next().unwrap_or_default(), + _ => "s", + } + } else { + unit + }; + let scale = match unit.trim_end_matches('.').to_lowercase().as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => 1.0, + "m" | "min" | "mins" | "minute" | "minutes" => 60.0, + "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0, + "d" | "day" | "days" => 86400.0, + _ => return None, + }; + total += n * scale; + seen = true; + } + seen.then_some(total) +} + +/// The longest burst gap `photo.analyze` accepts: a day. +pub const MAX_BURST_GAP: f32 = 86_400.0; + +/// Keep what was typed in the Assisted Culling value fields within what `photo.analyze` accepts +/// (a gap of 0 s to a day, a similarity of 0 to 1), not within the sliders' drag range. +pub fn clamp_cull_burst(gap: &mut f32, similarity: &mut f32) { + *gap = if gap.is_finite() { gap.clamp(0.0, MAX_BURST_GAP) } else { 10.0 }; + *similarity = if similarity.is_finite() { similarity.clamp(0.0, 1.0) } else { 0.93 }; +} + +/// The `photo.analyze` parameters of the Assisted Culling dialog, its values as they are. +pub fn cull_params(reject_below: f32, pick_best: bool, burst_gap: f32, similarity: f32) -> serde_json::Value { + let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); + if reject_below > 0.0 { + p["rejectBelow"] = json!(reject_below); + } + p +} + /// `90` → `1 min 30 s`. pub fn fmt_gap(v: f64) -> String { let v = v.round() as u64; @@ -1586,7 +1755,9 @@ pub fn fmt_gap(v: f64) -> String { 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), 60..3600 => format!("{} min {} s", v / 60, v % 60), 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), - 3600..86400 => format!("{} h {} min", v / 3600, v % 3600 / 60), + 3600..86400 if v.is_multiple_of(60) => format!("{} h {} min", v / 3600, v % 3600 / 60), + // the seconds too, so editing the text reads back the same gap + 3600..86400 => format!("{} h {} min {} s", v / 3600, v % 3600 / 60, v % 60), _ => crate::i18n::tr("1 day").into(), } } @@ -1666,10 +1837,8 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str let seg = &app.session.segmenter; if d.running { ui.add_space(4.0); - let frac = if d.total > 0 { d.done as f64 / d.total as f64 } else { 0.0 }; let text = format!("{:.2} / {:.2} GB · {}", gb(d.done), gb(d.total), d.file); - let r = ui.add(egui::ProgressBar::new(frac as f32).text(text)); - crate::widgets::register(ui.ctx(), "progress:samDownload", r.rect); + crate::progress::Progress::new(crate::progress::fraction_of(d.done, d.total)).label(text).widget("progress:samDownload").show(ui); let r = ui.button(crate::i18n::tr("Cancel Download")); crate::widgets::register(ui.ctx(), "button:samCancel", r.rect); if r.clicked() { @@ -1875,12 +2044,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted), Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), - Dialog::Cull { reject_below, pick_best } => { - let mut p = json!({"pickBest": pick_best}); - if *reject_below > 0.0 { - p["rejectBelow"] = json!(reject_below); - } - app.run("photo.analyze", p) + Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { + app.ui.cull_burst = Some((*burst_gap, *similarity)); + app.run("photo.analyze", cull_params(*reject_below, *pick_best, *burst_gap, *similarity)) } Dialog::SmartRules { id, name, rules, parent } => { // refused before anything changes (not even the name) @@ -1934,6 +2100,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso Dialog::LightroomCatalog { catalog, .. } => { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) } + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::confirm(app, d), Dialog::SaveEffect { name, group, fields, amount, brush } => { crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) } @@ -2330,3 +2497,132 @@ fn only_shows(dlg: &Dialog) -> bool { | Dialog::ViewOptions ) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date()) } + +#[cfg(test)] +mod gap_tests { + use super::{fmt_gap, parse_gap}; + + #[test] + fn gap_text_reads_back_what_it_shows() { + for v in [0.0, 1.0, 12.0, 59.0, 60.0, 90.0, 600.0, 3599.0, 3600.0, 3601.0, 3661.0, 3900.0, 7325.0, 86340.0, 86399.0] { + assert_eq!(parse_gap(&fmt_gap(v)), Some(v), "{}", fmt_gap(v)); + } + assert_eq!(parse_gap("1 day"), Some(86400.0)); + assert_eq!(parse_gap("45"), Some(45.0)); + assert_eq!(parse_gap(" 45s "), Some(45.0)); + assert_eq!(parse_gap("2min 5s"), Some(125.0)); + for bad in ["", "abc", "5 parsecs", "NaN", "1 min x"] { + assert_eq!(parse_gap(bad), None, "{bad:?}"); + } + // a day reads back in the UI language too ("1 Tag") + crate::i18n::set_language(crate::i18n::Locale::De); + let shown = fmt_gap(86400.0); + let back = parse_gap(&shown); + crate::i18n::set_language(crate::i18n::Locale::En); + assert_eq!((shown.as_str(), back), ("1 Tag", Some(86400.0))); + } + + #[test] + fn gaps_past_the_slider_read_back_too() { + // the Assisted Culling slider drags to 10 min; its value field takes up to a day + for (v, text) in [(1800.0, "30 min"), (5400.0, "1 h 30 min"), (3601.0, "1 h 0 min 1 s"), (86400.0, "1 day")] { + assert_eq!(fmt_gap(v), text); + assert_eq!(parse_gap(text), Some(v), "{text}"); + } + assert_eq!(parse_gap("1800"), Some(1800.0)); + assert_eq!(parse_gap("0"), Some(0.0)); + } +} + +#[cfg(test)] +mod cull_dialog_tests { + use std::time::Duration; + + use serde_json::json; + + use super::{clamp_cull_burst, cull_params}; + use crate::headless::Headless; + use crate::state::Dialog; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + #[test] + fn typed_values_are_kept_within_what_photo_analyze_accepts() { + for (gap, sim) in [(0.0, 0.0), (1800.0, 1.0), (86400.0, 0.5), (0.5, 0.995)] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), (gap, sim), "in range: kept as typed"); + } + for ((gap, sim), want) in [((-5.0, -0.1), (0.0, 0.0)), ((90000.0, 1.5), (86400.0, 1.0)), ((f32::NAN, f32::INFINITY), (10.0, 0.93))] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), want); + } + assert_eq!(cull_params(0.0, true, 1800.0, 1.0), json!({"pickBest": true, "burstGap": 1800.0, "similarity": 1.0})); + assert_eq!(cull_params(20.0, false, 0.0, 0.7)["rejectBelow"], 20.0); + } + + /// Click the value field right of a slider registered as `id`, type `text`, Enter. + fn type_value(h: &mut Headless, id: &str, text: &str) { + let r = h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")); + let spacing = h.view.ctx.global_style().spacing.clone(); + let x = r.left() + spacing.slider_width + spacing.item_spacing.x + 6.0; + assert_eq!(h.request("ui.click", json!({"x": x, "y": r.center().y}), T)["ok"], true); + h.step(); + for key in ["a", "Backspace"] { + assert_eq!(h.request("ui.key", json!({"key": key, "cmd": key == "a"}), T)["ok"], true); + } + assert_eq!(h.request("ui.text", json!({"text": text}), T)["ok"], true); + assert_eq!(h.request("ui.key", json!({"key": "Enter"}), T)["ok"], true); + h.step(); + h.step(); + } + + fn burst(h: &Headless) -> (f32, f32) { + match &h.app.ui.dialog { + Some(Dialog::Cull { burst_gap, similarity, .. }) => (*burst_gap, *similarity), + d => panic!("{d:?}"), + } + } + + #[test] + fn the_culling_value_fields_take_values_past_the_sliders() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.cull", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + assert_eq!(burst(&h), (10.0, 0.93)); + // half an hour and "only identical shots": past the sliders' 10 min and 0.99 + type_value(&mut h, "field:cullGap", "30 min"); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h), (1800.0, 1.0)); + // a day is as far as photo.analyze goes; more is held there + type_value(&mut h, "field:cullGap", "2 days"); + assert_eq!(burst(&h).0, 86400.0); + type_value(&mut h, "field:cullGap", "0"); + assert_eq!(burst(&h), (0.0, 1.0)); + type_value(&mut h, "field:cullGap", "1800"); + assert_eq!(burst(&h).0, 1800.0); + // a similarity between the slider's hundredths is kept to the thousandth + type_value(&mut h, "field:cullSimilar", "0.995"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "0.9951"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h).1, 1.0); + // OK hands them to photo.analyze as typed (it refuses values out of its range) + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + assert_eq!(h.app.ui.cull_burst, Some((1800.0, 1.0))); + // and the next opening starts from them, not from the sliders' range + h.app.run("dialog.cull", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(burst(&h), (1800.0, 1.0)); + } +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 03062d0c4..2ce723857 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -6,6 +6,7 @@ use egui::{Align2, Color32, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; use lightcraft_catalog::PhotoId; use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls}; use lightcraft_geom::Point; +use lightcraft_pipeline::{PARAM_AMOUNTS, ParametricCurve}; use lightcraft_raster::Histogram; use serde_json::{Value, json}; @@ -630,12 +631,19 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // row under the plot let hdr = hist.as_ref().and_then(|h| h.hdr); let legend = if hdr.is_some() { 26.0 } else { 0.0 }; - let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click_and_drag()); crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); register(ui.ctx(), "histogram", r); let p = ui.painter(); p.rect_filled(r, 0.0, t.button); let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0 - legend)); + // Lightroom Classic's tonal zones (Blacks / Shadows / Exposure / Highlights / Whites across the + // plot): a drag over one moves its control (the band and the readout show which and how far), a + // double-click puts it back to its default, and Shift drags a tenth as far + let zone = super::histogram::interact(app, ui, &resp, plot, id); + if let Some(a) = &zone { + super::histogram::paint_zone(p, plot, a, &t); + } if let Some(b) = hdr { // white for SDR, then one band per stop above SDR white: white too, or (Visualize HDR // range on) in its colours @@ -723,13 +731,11 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let mut clicked = resp.clicked(); // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep // clear of it, the left one too so they stay symmetric (#1640) - let scroll = ui.spacing().scroll; - let inset = 20.0 + scroll.bar_width + scroll.bar_outer_margin; - for (name, x, label, clipped) in [ - ("shadows", plot.left() + inset, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), - ("highlights", plot.right() - inset, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + let [shadow_rect, highlight_rect] = super::histogram::clipping_rects(ui, plot); + for (name, rect, label, clipped) in [ + ("shadows", shadow_rect, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), + ("highlights", highlight_rect, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), ] { - let rect = Rect::from_center_size(pos2(x, plot.top() + 14.0), vec2(24.0, 22.0)); let response = ui.interact(rect, ui.scope_id().with(("histogram-clipping", name)), Sense::click()); register(ui.ctx(), format!("histogram:{name}"), rect); response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); @@ -776,8 +782,11 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if clicked { let _ = app.run("view.clipping", json!({})); } - // EXIF strip - if let Some(ph) = app.session.catalog.photo(id) { + // the tonal zone the pointer is on replaces the EXIF strip while it is there + if let Some(text) = zone.as_ref().and_then(|a| a.readout()) { + register(ui.ctx(), "histogramReadout", r); + p.text(pos2(r.left() + r.width() / 2.0, r.bottom() - 13.0), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); + } else if let Some(ph) = app.session.catalog.photo(id) { let m = &ph.meta; let parts: Vec<String> = [ m.iso.map(|i| format!("ISO {i}")), @@ -930,6 +939,115 @@ fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histo p.add(mesh); } +/// Segments the graph's curve line is drawn with. +pub(crate) const CURVE_SAMPLES: i32 = 96; + +/// Close a graph drag's `develop` interaction, as one undo step. +/// +/// It is closed when the drag ends *or* the pointer comes up without egui reporting `drag_stopped` +/// for this widget (released outside the window, over a popup, or on a frame the widget does not +/// run). A dangling interaction would make every later edit a silent preview — Undo would then +/// appear to do nothing at all. Returns whether the drag is over, so the caller clears its state. +fn commit_interaction(app: &mut LightcraftApp, ui: &egui::Ui, dragging: bool, stopped: bool) -> bool { + let released = ui.input(|i| i.pointer.any_released()) || !ui.input(|i| i.pointer.primary_down()); + if !dragging || !(stopped || released) { + return false; + } + let _ = app.run("develop.endInteraction", json!({})); + true +} + +/// How close (screen points) to the curve a press counts as grabbing it. +const PARAM_GRAB: f32 = 14.0; + +/// The parametric curve's own drag: the graph shows the curve and nothing else, and dragging the +/// line bends the region the pointer is over, with that region's slider following on the same frame. +/// +/// The whole gesture runs inside one `develop` interaction, so it is one undo step (Ctrl+Z takes the +/// gesture back, not the last frame of it), and every write goes through `develop.set`, so the +/// slider, the curve and what is drawn are the same state rather than three things kept in step. +fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui::Response, d: &DevelopSettings) { + if r.width() <= 1.0 || r.height() <= 1.0 { + return; + } + let t = Tokens::get(ui.ctx()); + let model = ParametricCurve::new(&d.curve); + let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); + let from_screen_x = |q: Pos2| ((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64; + // the graph x of a point, and how far it is from the drawn curve (in screen points) + let near_curve = |q: Pos2| { + let x = from_screen_x(q); + to_screen(x, model.at(x)).distance(q) + }; + let drag_id = egui::Id::unique("curve-param-drag"); + let cursor_id = egui::Id::unique("curve-param-cursor"); + // (region, x, initial_amount, start_y): the region being dragged, the graph x the drag is anchored + // at, the region's starting amount, and the starting screen Y position. + let mut dragged: Option<(usize, f64, f64, f32)> = ui.data(|dd| dd.get_temp::<Option<(usize, f64, f64, f32)>>(drag_id)).flatten(); + let hovered = dragged.is_none() && resp.hover_pos().is_some_and(|q| near_curve(q) <= PARAM_GRAB); + let mut set: Option<(&'static str, f64)> = None; + // the press, not where the pointer is once the drag threshold has passed: a press slightly off + // the line is still a grab, and the pointer has usually left that spot by the time it starts + let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()); + if resp.drag_started() + && let Some(q) = press + && near_curve(q) <= PARAM_GRAB + { + let x = from_screen_x(q); + let _ = app.run("develop.beginInteraction", json!({"label": "Parametric Curve"})); + let region = model.region_at(x); + let initial_amount = model.amounts.get(region).copied().unwrap_or(0.0); + let start_pos = to_screen(x, model.at(x)); + dragged = Some((region, x, initial_amount, start_pos.y)); + ui.data_mut(|dd| dd.insert_temp(cursor_id, start_pos)); + } + if let Some((region, x, initial_amount, start_y)) = dragged + && resp.dragged() + { + // Follow the pointer's motion from where the curve was grabbed, so the press itself moves + // nothing and the line tracks the hand. ⌥ = a tenth of the motion, as the point curve does. + let alt = ui.input(|i| i.modifiers.alt); + let dd = ui.input(|i| i.pointer.delta()); + let step = if alt { vec2(dd.x * 0.1, dd.y * 0.1) } else { vec2(dd.x, dd.y) }; + let cursor = ui.data(|d| d.get_temp::<Pos2>(cursor_id)).unwrap_or(pos2(0.0, start_y)) + step; + ui.data_mut(|d| d.insert_temp(cursor_id, cursor)); + let delta_y = start_y - cursor.y; + let delta_level = (delta_y / r.height()) as f64; + let per_unit = model.level_per_unit(region, x); + let new_amount = + if per_unit.abs() > f64::EPSILON { (initial_amount + delta_level / per_unit).clamp(-100.0, 100.0).round() } else { initial_amount }; + if let Some(&id) = PARAM_AMOUNTS.get(region) { + set = Some((id, new_amount)); + } + } + if let Some((id, v)) = set { + let _ = app.run("develop.set", json!({"control": id, "value": v})); + } + let dropped = resp.drag_stopped(); + if commit_interaction(app, ui, dragged.is_some(), dropped) { + dragged = None; + ui.data_mut(|d| { + d.remove::<Pos2>(cursor_id); + d.remove::<Option<(usize, f64, f64, f32)>>(drag_id); + }); + } + ui.data_mut(|dd| dd.insert_temp(drag_id, dragged)); + if dragged.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + // the region under the pointer (or the one in hand), named as its slider below reads it + let shown = dragged.map(|(region, ..)| region).or_else(|| hovered.then(|| resp.hover_pos()).flatten().map(|q| model.region_at(from_screen_x(q)))); + if let Some(region) = shown + && let Some(&id) = PARAM_AMOUNTS.get(region) + && let Some(v) = set.and_then(|(s_id, v)| (s_id == id).then_some(v)).or_else(|| controls::get(d, id)) + { + let name = controls::find(id).map_or("", |s| s.label); + ui.painter().text(r.left_top() + vec2(6.0, 4.0), Align2::LEFT_TOP, format!("{name} {v:.0}"), t.font(11.0), t.text_dim); + } +} + fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { let t = Tokens::get(ui.ctx()); // channel selector @@ -1006,29 +1124,17 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height()); let from_screen = |q: Pos2| (((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64, ((r.bottom() - q.y) / r.height()).clamp(0.0, 1.0) as f64); if ch == "parametric" { - // draw the effective parametric curve by sampling the pipeline's region curve approximation + // The Effective curve: the model the renderer builds from the four region sliders, sampled + // here and rebuilt every frame. The graph holds no state of its own, so a slider moved by + // hand, a Reset or an Undo redraws it from the settings in the same frame it changes. let c = &d.curve; - let regions = [ - (0.0, c.split_shadows / 100.0, c.shadows), - (c.split_shadows / 100.0, c.split_mid / 100.0, c.darks), - (c.split_mid / 100.0, c.split_highlights / 100.0, c.lights), - (c.split_highlights / 100.0, 1.0, c.highlights), - ]; - let pts: Vec<Pos2> = (0..=64) - .map(|i| { - let x = i as f64 / 64.0; - let mut dd = 0.0; - for (a, b, amt) in regions { - let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); - let tt = ((x - ctr) / half).clamp(-1.0, 1.0); - dd += amt / 100.0 * 0.22 * (0.5 + 0.5 * (tt * std::f64::consts::PI).cos()); - } - to_screen(x, (x + dd * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0)) - }) - .collect(); + let model = ParametricCurve::new(c); + let pts: Vec<Pos2> = + (0..=CURVE_SAMPLES).map(|i| to_screen(i as f64 / CURVE_SAMPLES as f64, model.at(i as f64 / CURVE_SAMPLES as f64))).collect(); p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220)))); + param_drag(app, ui, r, &resp, d); curve_footer(app, ui, d); - for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] { + for &c in PARAM_AMOUNTS.iter().rev() { control(app, ui, d, c, true); } control(app, ui, d, "curve.refineSaturation", true); @@ -1160,9 +1266,11 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect(); let _ = app.run("develop.curve", json!({"channel": ch, "points": arr})); } - if resp.drag_stopped() { + // the drag is over when egui says so, or when the pointer comes up without it saying so (a + // release outside the window would otherwise leave the interaction open, and every later edit a + // silent preview that Undo cannot take back) + if commit_interaction(app, ui, dragging.is_some(), resp.drag_stopped()) { dragging = None; - let _ = app.run("develop.endInteraction", json!({})); } ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); curve_footer(app, ui, d); @@ -1553,7 +1661,8 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings // puck as it is at that moment. let grab_id = egui::Id::unique(("wheelGrab", key)); let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers)); - let grab = match resp.interact_pointer_pos() { + let pointer = wheel_pointer(ui, &resp); + let grab = match pointer { Some(q) if resp.dragged() || resp.clicked() => { let held = ui.data(|m| m.get_temp::<WheelGrab>(grab_id)).filter(|g| g.mods == mods && !resp.drag_started()); Some(held.unwrap_or_else(|| { @@ -1592,7 +1701,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings 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() + && let Some(q) = pointer { let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad)); let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); @@ -1600,6 +1709,13 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings if resp.drag_stopped() || resp.clicked() { let _ = app.run("develop.endInteraction", json!({})); } + // released, or Esc (egui ends a drag on it) + if resp.drag_stopped() + && let Some(held) = ui.ctx().data_mut(|m| m.remove_temp::<WheelCapture>(egui::Id::unique(WHEEL_CAPTURE))) + && !held.dropped + { + give_pointer_back(ui.ctx(), held.origin); + } if resp.double_clicked() { let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); } @@ -1703,6 +1819,99 @@ impl WheelGrab { } } +/// A wheel drag holds the pointer, as in Lightroom (issue #1899): it is hidden for the drag, the +/// puck follows the mouse's own travel, and on release the pointer is back where the button went +/// down, so a long (Alt) drag doesn't leave it far from the wheels. On macOS the pointer is locked +/// in place for the drag; elsewhere it moves unseen and is put back. The browser can't move it +/// (eframe's web host ignores the command), so there it only shows again where the drag ended. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct WheelCapture { + /// Where the button went down: the pointer goes back there. + origin: Pos2, + /// Where the pointer would be had it been free: where the drag began plus the travel since. + pos: Pos2, + /// Let go early (the window lost focus): the rest of this drag leaves the wheel alone. + dropped: bool, + /// The pass that last drew the dragged wheel. + pass: u64, +} + +/// One wheel is dragged at a time, so one capture for them all. +const WHEEL_CAPTURE: &str = "wheelCapture"; + +/// The mouse's travel this frame: the pointer's, or, while the pointer stands still (locked, or +/// against a screen edge), the raw motion the host reports. +fn wheel_travel(delta: egui::Vec2, motion: Option<egui::Vec2>) -> egui::Vec2 { + if delta != egui::Vec2::ZERO { + return delta; + } + motion.filter(|m| m.x.is_finite() && m.y.is_finite()).unwrap_or(egui::Vec2::ZERO) +} + +/// Where the wheel `resp` reads the pointer this frame: where it clicked, or for a drag the held +/// pointer ([`WheelCapture`]), `None` once a lost focus let go of it (Esc ends the drag itself: +/// egui stops a drag on Esc). +fn wheel_pointer(ui: &egui::Ui, resp: &egui::Response) -> Option<Pos2> { + let q = resp.interact_pointer_pos()?; + if !resp.dragged() { + return Some(q); + } + let ctx = ui.ctx(); + let id = egui::Id::unique(WHEEL_CAPTURE); + let held = ctx.data(|m| m.get_temp::<WheelCapture>(id)).filter(|_| !resp.drag_started()); + let mut cap = held.unwrap_or_else(|| { + if cfg!(target_os = "macos") { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked)); + } + WheelCapture { origin: ui.input(|i| i.pointer.press_origin()).unwrap_or(q), pos: q, ..Default::default() } + }); + cap.pass = ctx.cumulative_pass_nr(); + if !cap.dropped { + let (travel, unfocused) = ui.input(|i| { + // the event, not `focused`: a click that activates the window may land before its focus + let unfocused = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))); + (wheel_travel(i.pointer.delta(), i.pointer.motion()), unfocused) + }); + if !resp.drag_started() { + cap.pos += travel; + } + if unfocused { + give_pointer_back(ctx, cap.origin); + cap.dropped = true; + } else { + ctx.set_cursor_icon(egui::CursorIcon::None); + } + } + ctx.data_mut(|m| m.insert_temp(id, cap)); + (!cap.dropped).then_some(cap.pos) +} + +/// Unlock and show the pointer a wheel drag held, back at `origin`. +fn give_pointer_back(ctx: &egui::Context, origin: Pos2) { + if cfg!(target_os = "macos") { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)); + } + if cfg!(not(target_arch = "wasm32")) { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin)); + } +} + +/// Once a frame, after the panels: a held pointer stays hidden over whatever set its own cursor, +/// and is given back if no wheel drew this pass (the flyout closed or the view changed under the +/// drag), so it is never left locked or hidden. +pub(crate) fn wheel_pointer_frame_end(ctx: &egui::Context) { + let id = egui::Id::unique(WHEEL_CAPTURE); + let Some(cap) = ctx.data(|m| m.get_temp::<WheelCapture>(id)) else { return }; + if cap.pass != ctx.cumulative_pass_nr() { + ctx.data_mut(|m| m.remove::<WheelCapture>(id)); + if !cap.dropped { + give_pointer_back(ctx, cap.origin); + } + } else if !cap.dropped { + ctx.set_cursor_icon(egui::CursorIcon::None); + } +} + /// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. /// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the /// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 6b0bd4f3b..461c7d626 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -190,6 +190,9 @@ fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Val }; ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim)); + if st["checking"] == true { + crate::progress::spinner(ui, 12.0); + } let r = ui.label(text); register(ui.ctx(), format!("faces:device:{what}"), r.rect); if let Some(why) = why { @@ -312,10 +315,7 @@ fn scan_progress(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; } let text = format!("Scanning photos for faces: {left} left · {faces} faces so far"); - let bar = ui.add( - egui::ProgressBar::new((1.0 - left as f32 / peak as f32).clamp(0.0, 1.0)).desired_width(380.0).text(RichText::new(text).font(t.font(11.5))), - ); - register(ui.ctx(), "faces:scanProgress", bar.rect); + crate::progress::Progress::new(Some(1.0 - left as f32 / peak as f32)).label(text).widget("faces:scanProgress").show(ui); } fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, dl: Option<&Value>, can_run: bool) { @@ -361,17 +361,22 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, match (dl_state, dl) { (Some("running"), Some(d)) => { let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0)); - let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let text = if total > 0 && got >= total { "Checking…".to_string() } else { format!("{} of {} from {host}", mb(Some(got)), mb(Some(total))) }; - let bar = ui.add(egui::ProgressBar::new(frac).desired_width(260.0).text(RichText::new(text).font(t.font(11.5)))); - register(ui.ctx(), format!("faces:progress:{id}"), bar.rect); + crate::progress::Progress::new(crate::progress::fraction_of(got, total)) + .label(text) + .width(crate::progress::ROW_WIDTH.min(ui.available_width())) + .widget(format!("faces:progress:{id}")) + .show(ui); } (Some("done"), _) => { - ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); + }); } (Some("failed"), Some(d)) => { let why = sentence(d["error"].as_str().unwrap_or("The download failed")); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 8365a1ff8..592baf32f 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -130,22 +130,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .map(|(l, k, op)| (l.to_string(), json!({"ratingOp": k}), f.rating_op == op)) .collect(), ); - for i in 1..=5u8 { - let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click()); - crate::widgets::announce(&resp, format!("{i} ★")); - register(ui.ctx(), format!("filter:star{i}"), r); - let filled = i <= f.rating; - let c = if filled { - t.star - } else if resp.hovered() { - t.text_label - } else { - t.icon - }; - paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if filled { Icon::StarFilled } else { Icon::Star }, c); - if resp.clicked() { - filter(app, json!({"rating": if f.rating == i { 0 } else { i }})); - } + if let Some(r) = crate::rating::Rating::new("filter", f.rating).automation_ids("filter:star").show(ui) { + filter(app, json!({"rating": r})); } ui.add_space(14.0); @@ -172,13 +158,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // colour labels caption(ui, "Label"); - // several labels can be on at once (any of them matches) - let mut chosen: Vec<ColorLabel> = f.labels.clone(); - if let Some(l) = f.label - && !chosen.contains(&l) - { - chosen.push(l); - } + // several labels can be on at once (any of them matches); lit are those `label` and `labels` both let through + let chosen: Vec<ColorLabel> = f.labels_shown().unwrap_or_default(); for l in ColorLabel::ALL { let on = chosen.contains(&l); let name = format!("{l:?}").to_lowercase(); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index b7f8d1ab2..ebaf8ab07 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -800,16 +800,18 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } } else { let ph = img_rect.shrink(if square { 20.0 } else { 0.0 }); - p.rect_filled(ph, 0.0, Color32::from_gray(38)); + p.rect_filled(ph, 0.0, t.skeleton); // not drawn yet (or unreadable): the selection is still visible if active { p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); } else if selected { - p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside); + // a light theme's skeleton is itself mid-grey: the ring takes the dim text colour + let ring = if t.skeleton.r() > 0x80 { t.text_dim } else { Color32::from_gray(170) }; + p.rect_stroke(ph, 0.0, Stroke::new(2.0, ring), StrokeKind::Outside); } if app.renderer.failure(Slot::Thumb(id)).is_some() { // unreadable / missing file - p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text_dim); + p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text); } } // labels and badges @@ -857,10 +859,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_filled(bar, 0.0, Color32::from_black_alpha(120)); } let mut x = bar.left() + 6.0; - for i in 0..photo.rating { - paint(p, Rect::from_min_size(pos2(x + i as f32 * 13.0, bar.center().y - 6.0), vec2(12.0, 12.0)), Icon::StarFilled, t.star); - } - x += photo.rating as f32 * 13.0 + 4.0; + x += crate::rating::paint_rating(p, pos2(x, bar.center().y), photo.rating, crate::rating::RatingSize::Medium, t.star) + 4.0; match photo.flag { Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject), diff --git a/crates/ui-egui/src/panels/histogram.rs b/crates/ui-egui/src/panels/histogram.rs new file mode 100644 index 000000000..e6c0b50c1 --- /dev/null +++ b/crates/ui-egui/src/panels/histogram.rs @@ -0,0 +1,372 @@ +//! The histogram's tonal zones, as in Lightroom Classic: hovering one names it and shows its value, +//! dragging one sideways moves its Basic control, and a double-click puts that control back to its +//! default. +//! +//! The zone a press landed in stays locked for the whole drag, so the pointer may cross the others; +//! every step is a `develop.set` between `develop.beginInteraction` and `develop.endInteraction`, the +//! path the panel's sliders take: the draft render follows the drag live and the whole drag lands as +//! one undo step (`end_interaction`). + +use egui::{Painter, Pos2, Rect, Response, pos2}; +use lightcraft_catalog::PhotoId; +use lightcraft_develop::{ControlSpec, controls}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::panels::detail::Gesture; +use crate::theme::Tokens; +use crate::widgets::{register, shown_value}; + +/// One tonal zone across the histogram's plot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Zone { + /// The develop control the zone adjusts: an id of [`lightcraft_develop::controls`]. + pub control: &'static str, + /// The zone's span of the plot's width, normalized 0 (left) to 1 (right). + pub from: f32, + pub to: f32, +} + +/// Lightroom Classic's five histogram regions, left to right: Blacks, Shadows, Exposure, Highlights, +/// Whites. Each names the Basic panel's own control, so the zone has that control's range, default +/// and steps. +pub const ZONES: [Zone; 5] = [ + Zone { control: "light.blacks", from: 0.00, to: 0.15 }, + Zone { control: "light.shadows", from: 0.15, to: 0.35 }, + Zone { control: "light.exposure", from: 0.35, to: 0.65 }, + Zone { control: "light.highlights", from: 0.65, to: 0.85 }, + Zone { control: "light.whites", from: 0.85, to: 1.00 }, +]; + +/// How far a sideways drag moves a control: crossing the whole plot sweeps the control's whole range, +/// as dragging a slider's thumb across its track does. Shift is a tenth of that, as on the sliders. +const SENSITIVITY: f64 = 1.0; +const FINE: f64 = 0.1; + +impl Zone { + /// The zone's control spec; `None` for an id the control table no longer has. + pub fn spec(self) -> Option<&'static ControlSpec> { + controls::find(self.control) + } + + /// The zone's band in `plot`. + pub fn rect(self, plot: Rect) -> Rect { + let x = |f: f32| plot.left() + plot.width() * f; + Rect::from_min_max(pos2(x(self.from), plot.top()), pos2(x(self.to), plot.bottom())) + } +} + +/// The zone covering `fx`, a position across the plot normalized to 0..1. `None` off the plot (NaN +/// too); 1.0 is the last zone's, whose right edge is the plot's. +pub fn zone_at(fx: f32) -> Option<Zone> { + if !(0.0..=1.0).contains(&fx) { + return None; + } + ZONES.iter().copied().find(|zone| fx >= zone.from && fx < zone.to).or_else(|| ZONES.last().copied()) +} + +/// The value `base` moved by a sideways drag of `dx` points over a plot `width` points wide. The +/// result is clamped to the control's range; a drag that can't be measured (no width, a hostile +/// delta) or a non-finite `base` leaves `base` alone. +pub fn dragged(spec: &ControlSpec, base: f64, dx: f32, width: f32, fine: bool) -> f64 { + if !width.is_finite() || width <= 0.0 { + return base; + } + let travel = f64::from(dx) / f64::from(width) * if fine { FINE } else { SENSITIVITY }; + let v = base + travel * (spec.max - spec.min); + if v.is_finite() { v.clamp(spec.min, spec.max) } else { base } +} + +/// `v` on the control's own steps, as the panel's sliders round and dispatch it. A non-finite `v` +/// gives the control's default, as [`ControlSpec::clamp`] does. +pub fn quantized(spec: &ControlSpec, v: f64) -> f64 { + if !v.is_finite() { + return spec.default; + } + let step = spec.step.max(1e-9); + ((v / step).round() * step).clamp(spec.min, spec.max) +} + +/// What the histogram is acting on this frame. +#[derive(Clone, Copy, Debug)] +pub struct Active { + /// The zone under the pointer, or the one a drag locked. + pub zone: Zone, + /// The zone's control's live value. + pub value: f64, + /// A drag of the zone is in progress: its zone stays locked until the release. + pub dragging: bool, +} + +impl Active { + /// The readout under the histogram: "Exposure: +0.45 EV", "Shadows: +25". + pub fn readout(&self) -> Option<String> { + let spec = self.zone.spec()?; + let shown = shown_value(spec, self.value); + let value = if spec.id == "light.exposure" { format!("{shown} EV") } else { shown }; + Some(format!("{}: {value}", crate::i18n::tr(spec.label))) + } +} + +/// The rectangles of the clipping buttons in the plot's top corners (shadows left, highlights right). +pub fn clipping_rects(ui: &egui::Ui, plot: Rect) -> [Rect; 2] { + let scroll = ui.spacing().scroll; + clipping_rects_in(plot, scroll.bar_width, scroll.bar_outer_margin) +} + +/// The clipping indicator rectangles for a given `plot` and scroll-bar geometry. +pub fn clipping_rects_in(plot: Rect, scroll_bar_width: f32, scroll_bar_outer_margin: f32) -> [Rect; 2] { + let inset = 20.0 + scroll_bar_width + scroll_bar_outer_margin; + [ + Rect::from_center_size(pos2(plot.left() + inset, plot.top() + 14.0), egui::vec2(24.0, 22.0)), + Rect::from_center_size(pos2(plot.right() - inset, plot.top() + 14.0), egui::vec2(24.0, 22.0)), + ] +} + +/// The histogram's pointer input for this frame: the press locks its zone and opens the interaction, +/// the drag moves that control, a double-click resets it. Returns what the highlight and the readout +/// should show, `None` when the pointer is nowhere near a zone. +/// +/// `plot` is the plot's rect: the strip under it (the readout and the EXIF line) is not a zone, and +/// neither are the clipping buttons in the plot's top corners, which take their own presses. +pub fn interact(app: &mut LightcraftApp, ui: &egui::Ui, resp: &Response, plot: Rect, id: PhotoId) -> Option<Active> { + let width = plot.width(); + let clips = clipping_rects(ui, plot); + let in_plot = |q: Pos2| plot.contains(q) && !clips.iter().any(|c| c.contains(q)); + let zone_of = |q: Pos2| in_plot(q).then(|| (q.x - plot.left()) / width).and_then(zone_at); + // the zone the pointer is over, and the one a press (or a double-click) landed in: the press's + // origin, so a drag is judged by where it started + let hovered = resp.hover_pos().and_then(zone_of); + let pressed = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()).and_then(zone_of); + let was_dragging = matches!(app.gesture, Some(Gesture::HistogramDrag { .. })); + // a drag cut short — the release went elsewhere, the panel closed — leaves the interaction open: + // with no button held the gesture is over, as the crop tool's gestures decide too + if was_dragging && !resp.drag_stopped() && !ui.input(|i| i.pointer.any_down()) { + finish(app); + } + // 1. the press locks its zone: one interaction holds the whole drag as one undo step + if resp.drag_started() + && !was_dragging + && let Some(zone) = pressed + && let Some(spec) = zone.spec() + && app.run("develop.beginInteraction", json!({"label": spec.label})).is_ok() + { + app.ui.dragging_control = Some(spec.id.to_string()); + app.gesture = Some(Gesture::HistogramDrag { control: zone.control, value: value_of(app, id, spec) }); + } + // 2. the drag moves the locked zone's control, wherever the pointer has gone since + if let Some(Gesture::HistogramDrag { control, value }) = app.gesture.clone() + && let Some(spec) = controls::find(control) + { + if resp.dragged() || resp.drag_stopped() { + // The frame a drag is first reported, take the whole travel from the press: egui only + // starts reporting a drag once the pointer has left its click threshold, and those first + // points are the user's movement too. Later frames add their own delta (a one-frame flick + // reports the drag as started and stopped together: then this is its delta), so Shift — + // a tenth — scales the travel it was held for, as the sliders' fine drag does. The + // unrounded value is kept from frame to frame, so a slow drag adds up instead of rounding + // back to the step it already has. + let dx = if resp.drag_started() { + ui.input(|i| i.pointer.total_drag_delta()).map_or(resp.drag_delta().x, |d| d.x) + } else { + resp.drag_delta().x + }; + let next = dragged(spec, value, dx, width, ui.input(|i| i.modifiers.shift)); + app.gesture = Some(Gesture::HistogramDrag { control, value: next }); + let v = quantized(spec, next); + if value_of(app, id, spec) != v { + let _ = app.run("develop.set", json!({"control": control, "value": v})); + } + } + if resp.drag_stopped() { + finish(app); + } + } + // 3. a double-click in a zone puts that control back to its default, in one undo step + if resp.double_clicked() + && let Some(zone) = pressed + && let Some(spec) = zone.spec() + { + let _ = app.run("develop.beginInteraction", json!({"label": spec.label})); + app.ui.dragging_control = Some(spec.id.to_string()); + let _ = app.run("develop.set", json!({"control": spec.id, "value": spec.default})); + finish(app); + } + // the highlight and the readout follow the drag's locked zone, else the pointer's + let zone = match app.gesture { + Some(Gesture::HistogramDrag { control, .. }) => ZONES.iter().copied().find(|zone| zone.control == control), + _ => hovered, + }?; + let spec = zone.spec()?; + let dragging = matches!(app.gesture, Some(Gesture::HistogramDrag { .. })); + if dragging || hovered.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + register(ui.ctx(), "histogram:zone", zone.rect(plot)); + Some(Active { zone, value: value_of(app, id, spec), dragging }) +} + +/// End the histogram drag: the interaction commits the whole drag as one undo step. +fn finish(app: &mut LightcraftApp) { + app.gesture = None; + app.ui.dragging_control = None; + let _ = app.run("develop.endInteraction", json!({})); +} + +/// A control's value on the photo, or its default when the photo has no settings yet. +fn value_of(app: &LightcraftApp, id: PhotoId, spec: &ControlSpec) -> f64 { + controls::get(&app.session.develop_of(id).unwrap_or_default(), spec.id).unwrap_or(spec.default) +} + +/// The band the active zone draws over the plot: a subtle translucent highlight, a little stronger +/// while the zone is being dragged. Painted behind the histogram's curves. +pub fn paint_zone(painter: &Painter, plot: Rect, active: &Active, tokens: &Tokens) { + let tint = if active.dragging { tokens.pressed } else { tokens.hover }; + painter.rect_filled(active.zone.rect(plot), 0.0, tint.gamma_multiply(if active.dragging { 0.75 } else { 0.45 })); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec_of(control: &str) -> &'static ControlSpec { + controls::find(control).unwrap_or_else(|| panic!("{control} is not a control")) + } + + /// The five zones tile the plot left to right, each naming a real Basic tone control of the + /// range this app documents (the panel's own specs, not a copy of them). + #[test] + fn the_zones_tile_the_plot_and_name_real_tone_controls() { + assert_eq!(ZONES.len(), 5); + assert_eq!(ZONES.first().map(|z| z.from), Some(0.0)); + assert_eq!(ZONES.last().map(|z| z.to), Some(1.0)); + for pair in ZONES.windows(2) { + assert_eq!(pair[0].to, pair[1].from, "{:?} does not meet {:?}", pair[0].control, pair[1].control); + } + for zone in ZONES { + let spec = spec_of(zone.control); + assert_eq!(spec.section, lightcraft_develop::Section::Light, "{}", spec.id); + assert_eq!(spec.default, 0.0, "{}", spec.id); + let (min, max) = if spec.id == "light.exposure" { (-5.0, 5.0) } else { (-100.0, 100.0) }; + assert_eq!((spec.min, spec.max), (min, max), "{}", spec.id); + } + } + + /// A position across the plot picks its zone, at the boundaries too; anything off the plot — + /// including NaN — picks none. + #[test] + fn a_position_across_the_plot_picks_its_zone() { + for (fx, control) in [ + (0.0, "light.blacks"), + (0.14, "light.blacks"), + (0.15, "light.shadows"), + (0.34, "light.shadows"), + (0.35, "light.exposure"), + (0.64, "light.exposure"), + (0.65, "light.highlights"), + (0.84, "light.highlights"), + (0.85, "light.whites"), + (0.99, "light.whites"), + (1.0, "light.whites"), + ] { + assert_eq!(zone_at(fx).map(|z| z.control), Some(control), "{fx}"); + } + for off in [-0.01, -1.0, 1.01, f32::NAN, f32::INFINITY] { + assert!(zone_at(off).is_none(), "{off}"); + } + } + + /// The zones' bands are their share of the plot's width, in order. + #[test] + fn a_zones_band_is_its_share_of_the_plot() { + let plot = Rect::from_min_max(pos2(100.0, 20.0), pos2(400.0, 200.0)); + let bands: Vec<Rect> = ZONES.iter().map(|z| z.rect(plot)).collect(); + assert_eq!(bands.first().map(|b| b.left()), Some(plot.left())); + assert_eq!(bands.last().map(|b| b.right()), Some(plot.right())); + for band in &bands { + assert_eq!((band.top(), band.bottom()), (plot.top(), plot.bottom())); + } + for pair in bands.windows(2) { + assert_eq!(pair[0].right(), pair[1].left()); + } + let exposure = bands.get(2).copied().unwrap_or(plot); + assert!((exposure.width() - plot.width() * 0.30).abs() < 1e-3, "{exposure:?}"); + } + + /// A drag across the whole plot sweeps the whole range; Shift is a tenth; the ends clamp; the + /// unrounded value a slow drag accumulates keeps adding up. + #[test] + fn a_drag_sweeps_the_control_and_clamps_at_its_ends() { + let exposure = spec_of("light.exposure"); + let blacks = spec_of("light.blacks"); + assert!((dragged(exposure, 0.0, 20.0, 200.0, false) - 1.0).abs() < 1e-9, "a tenth of the plot is a tenth of ±5 EV"); + assert!((dragged(blacks, 0.0, 20.0, 200.0, false) - 20.0).abs() < 1e-9); + assert!((dragged(blacks, 0.0, 200.0, 200.0, false) - 100.0).abs() < 1e-9); + assert_eq!(dragged(blacks, 0.0, 400.0, 200.0, false), 100.0, "past the right end it stays"); + assert_eq!(dragged(blacks, 0.0, -400.0, 200.0, false), -100.0, "and past the left end"); + assert!((dragged(blacks, 0.0, 20.0, 200.0, true) - 2.0).abs() < 1e-9, "Shift is a tenth of the travel"); + assert!((dragged(blacks, 0.0, -20.0, 200.0, false) + 20.0).abs() < 1e-9, "left lowers"); + // a drag whose frames are each smaller than one step still adds up over the whole drag (the + // deltas are f32 points, so the sum carries their rounding) + let mut acc = 0.0; + for _ in 0..10 { + acc = dragged(blacks, acc, 0.4, 200.0, false); + } + assert!((acc - 4.0).abs() < 1e-6, "{acc}"); + // hostile input: no width, a hostile delta, a base that isn't a number + assert_eq!(dragged(blacks, 5.0, 20.0, 0.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, 20.0, -10.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, f32::NAN, 200.0, false), 5.0); + assert_eq!(dragged(blacks, 5.0, f32::INFINITY, 200.0, false), 5.0); + assert!(dragged(blacks, f64::NAN, 20.0, 200.0, false).is_nan(), "a base that isn't a number stays as it is"); + } + + /// Dispatched values land on the control's steps (as the sliders dispatch them) and inside its + /// range; a value that isn't a number gives the default rather than a NaN. + #[test] + fn a_dispatched_value_lands_on_the_controls_steps() { + let blacks = spec_of("light.blacks"); + let exposure = spec_of("light.exposure"); + assert_eq!(quantized(blacks, 20.4), 20.0); + assert_eq!(quantized(blacks, 20.6), 21.0); + assert_eq!(quantized(blacks, 1e9), 100.0); + assert_eq!(quantized(blacks, -1e9), -100.0); + assert_eq!(quantized(blacks, f64::NAN), 0.0); + assert!((quantized(exposure, 0.454) - 0.45).abs() < 1e-9, "{}", quantized(exposure, 0.454)); + for v in [-1e9, -3.7, 0.0, 2.2, 1e9] { + let q = quantized(exposure, v); + assert!((exposure.min..=exposure.max).contains(&q), "{v} → {q}"); + } + } + + /// The readout names the zone's control and shows its value: EV for the exposure, the plain + /// signed number otherwise, exactly as the sliders show them. + #[test] + fn the_readout_names_the_zone_and_its_value() { + let active = |control: &str, value: f64, dragging: bool| Active { + zone: ZONES.iter().copied().find(|z| z.control == control).unwrap_or(ZONES[0]), + value, + dragging, + }; + assert_eq!(active("light.exposure", 0.45, false).readout().as_deref(), Some("Exposure: +0.45 EV")); + assert_eq!(active("light.exposure", -2.0, true).readout().as_deref(), Some("Exposure: -2.00 EV")); + assert_eq!(active("light.shadows", 25.0, true).readout().as_deref(), Some("Shadows: +25")); + assert_eq!(active("light.blacks", 0.0, false).readout().as_deref(), Some("Blacks: 0")); + assert_eq!(active("light.highlights", -100.0, false).readout().as_deref(), Some("Highlights: -100")); + assert_eq!(active("light.whites", 13.0, false).readout().as_deref(), Some("Whites: +13")); + } + + /// The clipping rectangles sit in the plot's top corners, symmetric from both edges. + #[test] + fn clipping_rects_sit_in_the_plots_top_corners() { + let plot = Rect::from_min_max(pos2(100.0, 20.0), pos2(400.0, 200.0)); + let [low, high] = clipping_rects_in(plot, 10.0, 4.0); + assert!(plot.contains(low.center())); + assert!(plot.contains(high.center())); + assert_eq!(low.top(), plot.top() + 3.0); + assert_eq!(high.top(), plot.top() + 3.0); + assert_eq!(low.size(), egui::vec2(24.0, 22.0)); + assert_eq!(high.size(), egui::vec2(24.0, 22.0)); + assert!((low.left() - plot.left() - (plot.right() - high.right())).abs() < 1e-4); + } +} diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 262a22530..d8d6c3a7c 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -205,6 +205,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId } // the tick box: does the selection have it? let state = ticks.tick(&r.path); + let inside = ticks.inside(&r.path); let boxr = Rect::from_center_size(pos2(x + 22.0, cy), vec2(13.0, 13.0)); let on = !selection.is_empty(); // (it senses drags too, so a press there that moves doesn't pick the keyword up) @@ -225,14 +226,27 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId } Tick::No => { ui.painter().rect_stroke(boxr, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside); + // the selection has a keyword inside this one: a dot in the empty box, so a folded + // keyword shows that it holds something applied (a shape of its own beside the tick + // and the dash, which are about the keyword itself) + if !inside.is_empty() { + let dot = Rect::from_center_size(boxr.center(), vec2(5.0, 5.0)); + ui.painter().circle_filled(dot.center(), 2.5, t.text); + register(ui.ctx(), format!("keywordInside:{}", r.path), dot); + } } } // (with nothing selected it does nothing, and says nothing) let tb = if on { - tb.on_hover_text(crate::i18n::tr(match state { + let does = crate::i18n::tr(match state { Tick::All => "Remove from Selected Photos", _ => "Add to Selected Photos", - })) + }); + // what is inside comes first: it is what the box shows + match inside_said(inside) { + Some(said) => tb.on_hover_text(format!("{said}\n{does}")), + None => tb.on_hover_text(does), + } } else { tb }; @@ -278,6 +292,17 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId crate::widgets::context_menu(&resp, |ui| menu(app, ui, &r.path, selection)); } +/// How many of the keywords inside one are named before the rest are counted. +const INSIDE_NAMED: usize = 4; + +/// "Inside: Rome, Venice": the applied keywords inside a keyword, the first few by name. +fn inside_said(inside: &[String]) -> Option<String> { + let named = inside.iter().take(INSIDE_NAMED).map(String::as_str).collect::<Vec<_>>().join(", "); + let more = inside.len().saturating_sub(INSIDE_NAMED); + let names = if more > 0 { format!("{named} {}", crate::i18n::tr_format!("+{n} more", n = more)) } else { named }; + (!inside.is_empty()).then(|| crate::i18n::tr_format!("Inside: {names}", names = names)) +} + /// A row while a keyword or photos are dragged: outlined under the pointer when the drop would /// do something (a keyword never goes inside itself), and a release there does it: nests the /// keyword inside this one, or gives this keyword to the photos. @@ -488,23 +513,51 @@ pub(crate) struct Ticks { photos: usize, /// Photos with it, by cleaned lower-case keyword. counts: std::collections::HashMap<String, usize>, + /// The applied keywords inside each keyword (by cleaned lower-case keyword): their own names, + /// in order, each once. What a folded keyword's box says of what it holds. + inside: std::collections::HashMap<String, Vec<String>>, } impl Ticks { pub(crate) fn of(catalog: &Catalog, photos: &[PhotoId]) -> Ticks { let photos = super::keywording::distinct(catalog, photos); let mut counts: std::collections::HashMap<String, usize> = Default::default(); + // (each keyword as it was first written, to name it by) + let mut shown: std::collections::HashMap<String, String> = Default::default(); let mut seen = std::collections::HashSet::new(); for p in &photos { seen.clear(); for k in &p.meta.keywords { - let key = lightcraft_catalog::keywords::clean(k).to_lowercase(); + let clean = lightcraft_catalog::keywords::clean(k); + let key = clean.to_lowercase(); if seen.insert(key.clone()) { - *counts.entry(key).or_default() += 1; + *counts.entry(key.clone()).or_default() += 1; + shown.entry(key).or_insert(clean); } } } - Ticks { photos: photos.len(), counts } + // every keyword above an applied one holds it: worked out here, once per selection, from the + // keywords the selection has (not per row and frame) + let mut inside: std::collections::HashMap<String, Vec<String>> = Default::default(); + let mut applied: Vec<(&String, &String)> = shown.iter().collect(); + applied.sort(); + for (key, as_written) in applied { + let name = as_written.rsplit('|').next().unwrap_or(as_written); + for (at, _) in key.match_indices('|') { + let Some(above) = key.get(..at) else { continue }; + let names = inside.entry(above.to_string()).or_default(); + if !names.iter().any(|n| same(n, name)) { + names.push(name.to_string()); + } + } + } + Ticks { photos: photos.len(), counts, inside } + } + + /// The applied keywords inside `path` (their own names): what the selection has of the + /// keywords below it, at any depth. Empty for a keyword with nothing applied inside. + pub(crate) fn inside(&self, path: &str) -> &[String] { + self.inside.get(&lightcraft_catalog::keywords::clean(path).to_lowercase()).map_or(&[], Vec::as_slice) } /// The tick box of `path`: a photo counts when it has the keyword itself (any case); one with @@ -621,4 +674,31 @@ mod tests { let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]); assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some); } + + /// Given photos that have keywords inside other keywords, when the list is asked what is + /// inside a keyword for the selection, then every keyword above an applied one names it (by + /// its own name, once, in order), whichever case it was written in and however deep; a keyword + /// with nothing applied inside, a keyword that is itself the applied one, and an empty + /// selection name nothing. + #[test] + fn a_keyword_names_the_applied_keywords_inside_it() { + let (c, ids) = library(&[&["Travel|Italy|Rome", "beach"], &["travel|italy|venice", "travel|Spain"], &["travel|italy|ROME"]]); + let all = Ticks::of(&c, &ids); + assert_eq!(all.inside("travel"), ["Italy|Rome", "Italy|venice", "Spain"].map(|k| k.rsplit('|').next().unwrap())); + assert_eq!(all.inside("Travel|Italy"), ["Rome", "venice"]); + assert_eq!(all.inside("travel|italy|rome"), [] as [&str; 0], "nothing is inside the applied keyword itself"); + assert_eq!(all.inside("travel|spain"), [] as [&str; 0]); + assert_eq!(all.inside("beach"), [] as [&str; 0]); + assert_eq!(all.inside("weddings"), [] as [&str; 0], "a keyword no photo has anything of"); + // the selection decides: the third photo alone has nothing of Spain or Venice + assert_eq!(Ticks::of(&c, &ids[2..]).inside("travel"), ["ROME"]); + assert_eq!(Ticks::of(&c, &[]).inside("travel"), [] as [&str; 0]); + // a keyword applied itself and with one inside: the tick is its own, and what is inside is still named + let (c, ids) = library(&[&["travel", "travel|Italy"]]); + let t = Ticks::of(&c, &ids); + assert_eq!((t.tick("travel"), t.inside("travel")), (Tick::All, &["Italy".to_string()][..])); + // a name that only starts like another is not inside it + let (c, ids) = library(&[&["travelling|Rome"]]); + assert_eq!(Ticks::of(&c, &ids).inside("travel"), [] as [&str; 0]); + } } diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index ac0dff431..32e308145 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -2,14 +2,91 @@ //! 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. +//! +//! The same window lists the recent libraries to choose from when Settings ▸ General ▸ At launch +//! is *Ask me*, or when Alt/Option is held as the window first appears (issue #1290): Open, Open +//! Another…, New Library…, Remove from List and Quit, with the arrow keys, Return and Esc. +//! [`launch_plan`] decides at launch which of the two the desktop app starts with. +//! +//! Whether each recent library can be opened is checked on a worker ([`RecentChecks`]), never on +//! the UI thread: a library on a network share whose server is gone, or on a disk that has to +//! spin up, can take seconds to answer. Until its answer comes the list shows the row as +//! "Checking…" (it can still be opened: opening says why if it can't), and File ▸ Open Recent +//! Library keeps it enabled; the same answers grey out the menu's entries. + +use std::path::{Path, PathBuf}; use egui::{Align2, RichText, vec2}; use serde_json::json; use crate::LightcraftApp; +use crate::state::{AppSettings, LaunchLibrary}; use crate::theme::Tokens; use crate::widgets::register; +/// How long after the window appears holding Alt/Option still asks (egui time, seconds). The +/// modifier keys can't be read before the first frame, and macOS reports one held since before +/// launch only once it changes, so it is watched over the first frames. +pub const ALT_SECONDS: f64 = 2.0; + +/// What the desktop app opens at launch (issue #1290). +#[derive(Clone, Debug, PartialEq)] +pub enum Launch { + /// Open this library folder (`None`: there is nowhere to keep one; the problem window says + /// so). `alt_asks`: holding Alt/Option as the window appears offers the recent libraries. + Open { dir: Option<PathBuf>, alt_asks: bool }, + /// Open nothing yet: the window lists the recent libraries ([`LibraryProblem::choose`]). + Ask, +} + +/// What to open at launch: `--library` (`flag`), else `$LIGHTCRAFT_LIBRARY` (`env`), else with +/// *Ask me* the list, else the library last opened, else `default` (the default location). +/// `settings` is `None` without a settings file; `memory` (`--memory`) never asks. +pub fn launch_plan(memory: bool, flag: Option<PathBuf>, env: Option<PathBuf>, settings: Option<&AppSettings>, default: Option<PathBuf>) -> Launch { + if memory { + return Launch::Open { dir: None, alt_asks: false }; + } + if let Some(dir) = flag.or(env) { + return Launch::Open { dir: Some(dir), alt_asks: false }; + } + if settings.is_some_and(|s| s.at_launch == LaunchLibrary::Ask) { + return Launch::Ask; + } + let last = settings.map(|s| s.library_path.clone()).filter(|p| !p.is_empty()).map(PathBuf::from); + Launch::Open { dir: last.or(default), alt_asks: true } +} + +/// One library in the launch window's list. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RecentLibrary { + pub path: String, + /// When it was last opened (ISO 8601), if known. + pub opened: Option<String>, + /// Why it can't be opened (`None`: it can, or it is still being checked), as checked when + /// the window opened or when opening it failed. + pub unavailable: Option<String>, + /// Its check hasn't answered yet ([`RecentChecks`]). + pub checking: bool, + /// Its check has gone on for longer than [`SLOW_AFTER`] (the drive may be asleep or offline). + pub slow: bool, +} + +/// The launch window's list of recent libraries (issue #1290). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Chooser { + /// Newest first, as File ▸ Open Recent Library lists them. + pub libraries: Vec<RecentLibrary>, + /// The highlighted row (`None`: the list is empty). + pub selected: Option<usize>, + /// The person moved the highlight: answers arriving later don't move it again. + pub touched: bool, + /// Its rows' checks were started ([`logic`]). + pub asked: bool, + /// The library open behind the window (Alt/Option held at launch), by + /// [`lightcraft_engine::Session::library_generation`]: Esc keeps it. `None`: no library is open. + pub over: Option<u64>, +} + /// A library that failed to open (set by the host at launch). #[derive(Clone, Debug, Default, PartialEq)] pub struct LibraryProblem { @@ -24,25 +101,411 @@ pub struct LibraryProblem { /// 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, + /// The window lists the recent libraries to choose from instead (issue #1290); `error` then + /// says why the last one tried didn't open. + pub chooser: Option<Chooser>, } impl LibraryProblem { + /// The window for a library at `path` that failed to open with `error`. pub fn new(path: impl Into<String>, error: impl Into<String>) -> LibraryProblem { LibraryProblem { path: path.into(), error: error.into(), can_retry: true, ..Default::default() } } + /// The window listing the recent libraries, each checked now without writing anything + /// ([`unavailable`]): one that is gone, isn't a LightCraft library any more or can't be read + /// is greyed out with the reason. The last library opened is selected, or else the first one + /// that can be opened. + #[cfg(test)] + pub(crate) fn choose(settings: &AppSettings) -> LibraryProblem { + let mut p = LibraryProblem::choose_later(settings); + if let Some(c) = p.chooser.as_mut() { + for l in &mut c.libraries { + l.unavailable = unavailable(&l.path).map(|u| u.text()); + l.checking = false; + } + c.selected = initial_selection(&c.libraries, &settings.library_path); + c.asked = true; + } + p + } + + /// [`LibraryProblem::choose`] without looking at any folder: every row is "Checking…" until + /// [`logic`] has the answers from a worker ([`RecentChecks`]). The last library opened is + /// selected meanwhile (or the first row); once every answer is in, the selection follows the + /// same rule as [`LibraryProblem::choose`] unless the person moved it. + pub fn choose_later(settings: &AppSettings) -> LibraryProblem { + // as many as File ▸ Open Recent Library keeps (a hand-edited settings file may hold more) + let libraries: Vec<RecentLibrary> = settings + .recent_libraries + .iter() + .take(crate::state::RECENT_LIBRARIES) + .map(|path| RecentLibrary { + path: path.clone(), + opened: settings.recent_opened.get(path).cloned(), + unavailable: None, + checking: true, + slow: false, + }) + .collect(); + let selected = libraries.iter().position(|l| l.path == settings.library_path).or((!libraries.is_empty()).then_some(0)); + LibraryProblem { chooser: Some(Chooser { libraries, selected, over: None, touched: false, asked: false }), ..Default::default() } + } + + /// The session promises to save nothing (the failed-library window or Continue Without + /// Saving): the host writes no settings meanwhile. The launch list is not such a session. + pub fn saves_nothing(&self) -> bool { + self.chooser.is_none() + } + /// `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()}) + let chooser = self.chooser.as_ref().map(|c| { + let libraries: Vec<serde_json::Value> = c + .libraries + .iter() + .map(|l| json!({"path": l.path, "lastOpened": l.opened, "available": l.unavailable.is_none(), "checking": l.checking, "reason": l.unavailable})) + .collect(); + json!({"libraries": libraries, "selected": c.selected, "libraryOpen": c.over.is_some()}) + }); + json!({"path": self.path, "error": self.error, "temporarySession": self.dismissed, "pendingImport": self.pending_import.len(), "chooser": chooser}) } } +/// The row first selected: the last library opened if it can be opened, else the first one that +/// can, else the first row. +fn initial_selection(libraries: &[RecentLibrary], last: &str) -> Option<usize> { + let can = |l: &RecentLibrary| l.unavailable.is_none(); + libraries.iter().position(|l| l.path == last && can(l)).or_else(|| libraries.iter().position(can)).or((!libraries.is_empty()).then_some(0)) +} + +/// Why a recent library can't be opened, found on a worker and put into words on the UI thread +/// (the interface language is per thread). +#[derive(Clone, Debug, PartialEq)] +pub enum Unavailable { + /// Nothing at that path (moved, renamed, or on a disconnected drive). + NotFound, + /// A file, not a folder. + NotAFolder, + /// The folder can't be listed (the system's reason). + Unreadable(String), + /// The folder holds no LightCraft library any more. + NoLibrary, + /// The library's catalog file can't be opened (the system's reason). + CatalogUnreadable(String), + /// The check itself failed (its worker stopped, or couldn't start). + CheckFailed(String), + /// Looking at the path failed without saying it is gone (a parent folder it may not read, a + /// share that timed out; the system's reason). + LookupFailed(String), +} + +impl Unavailable { + /// The folder isn't a library that is there to open (gone, a file, or not a library any more): + /// what greys out File ▸ Open Recent Library and makes `app.recentLibraries` say `exists: false`. + /// A folder that can't be listed, an unreadable catalog or a check that failed doesn't say the + /// library is gone (the launch window still shows why, and offers Open only for one it checked). + pub fn is_missing(&self) -> bool { + // a folder that refuses to list is there (permissions, macOS privacy): opening it says why + matches!(self, Unavailable::NotFound | Unavailable::NotAFolder | Unavailable::NoLibrary) + } + + /// In the interface language. + pub fn text(&self) -> String { + match self { + Unavailable::NotFound => crate::i18n::tr("Not found: moved, renamed or on a disconnected drive").to_string(), + Unavailable::NotAFolder => crate::i18n::tr("This is a file, not a library folder").to_string(), + Unavailable::Unreadable(e) => format!("{} {e}", crate::i18n::tr("This folder can't be read:")), + Unavailable::NoLibrary => crate::i18n::tr("There is no LightCraft library in this folder any more").to_string(), + Unavailable::CatalogUnreadable(e) => format!("{} {e}", crate::i18n::tr("The library's catalog can't be read:")), + Unavailable::CheckFailed(e) => e.clone(), + Unavailable::LookupFailed(e) => format!("{} {e}", crate::i18n::tr("This library couldn't be checked:")), + } + } +} + +/// How old an answer must be to be asked again: a library not found, on its own (a drive plugged +/// back in); any other, when the window comes back to the front (a library moved away meanwhile). +/// Only "not found" goes on a timer: looking again at the others would wake every external drive +/// and network share in the list over and over. +pub const RECHECK_AFTER: std::time::Duration = std::time::Duration::from_secs(30); +/// How long a row stays "Checking…" before it says the drive isn't answering (it can still be +/// opened: opening says why if it can't). +pub const SLOW_AFTER: std::time::Duration = std::time::Duration::from_secs(5); + +/// The activity stack's row for a check (English: the stack translates it). +const CHECK_LABEL: &str = "Checking recent libraries"; + +/// One path's answer and when it came. +#[derive(Clone, Debug)] +struct Answer { + why: Option<Unavailable>, + at: web_time::Instant, + /// The root UI frame that applied this answer ([`egui::Context::cumulative_frame_nr_for`]). + /// Scheduling must not ask about a check answer again in the frame that delivered it, which + /// also covers every layout pass of that frame; `None` for an answer no check worker made + /// (`opened`, or a check that couldn't start). + delivered_frame: Option<u64>, +} + +impl Answer { + /// An answer from a check worker, delivered in root UI frame `delivered_frame`. + fn from_check(why: Option<Unavailable>, delivered_frame: u64) -> Answer { + Answer { why, at: web_time::Instant::now(), delivered_frame: Some(delivered_frame) } + } + + /// An answer no check worker made, with no publication fence: it may be asked about again on + /// the next frame as before. + fn local(why: Option<Unavailable>) -> Answer { + Answer { why, at: web_time::Instant::now(), delivered_frame: None } + } +} + +/// Answers to "can this recent library be opened?", each found on a worker thread of its own (a +/// library on a network share whose server is gone, or on a sleeping disk, can take seconds or +/// never answer; the others don't wait for it). +pub struct RecentChecks { + known: std::collections::HashMap<String, Answer>, + /// Paths being checked, and since when. + pending: std::collections::HashMap<String, web_time::Instant>, + /// How one path is checked ([`unavailable`]; tests put a slow one here). + check: std::sync::Arc<dyn Fn(&str) -> Option<Unavailable> + Send + Sync>, + /// [`RECHECK_AFTER`] (shorter in tests). + recheck_after: std::time::Duration, + /// Whether the window had the focus on the last frame: answers are refreshed when it comes back. + pub(crate) focused: bool, +} + +impl Default for RecentChecks { + /// Nothing known yet, checking with [`unavailable`]. + fn default() -> Self { + RecentChecks { + known: Default::default(), + pending: Default::default(), + check: std::sync::Arc::new(unavailable), + recheck_after: RECHECK_AFTER, + focused: false, + } + } +} + +impl RecentChecks { + /// Why `path` can't be opened, if a check said so (`None`: it can, or it isn't known yet). + pub fn reason(&self, path: &str) -> Option<&Unavailable> { + self.known.get(path).and_then(|a| a.why.as_ref()) + } + + /// Whether the library at `path` is there: `Some(false)` when a check found it missing + /// ([`Unavailable::is_missing`]), `Some(true)` when one found it, `None` when no check could tell. + pub fn exists(&self, path: &str) -> Option<bool> { + match self.known.get(path)?.why.as_ref() { + None => Some(true), + Some(why) if why.is_missing() => Some(false), + Some(_) => None, + } + } + + /// Whether a check of `path` has answered. + pub fn is_known(&self, path: &str) -> bool { + self.known.contains_key(path) + } + + /// Whether `path` is being checked. + pub fn is_checking(&self, path: &str) -> bool { + self.pending.contains_key(path) + } + + /// Since when `path` has been checked, if it is. + fn checking_since(&self, path: &str) -> Option<web_time::Instant> { + self.pending.get(path).copied() + } + + /// A check is running. + pub fn is_running(&self) -> bool { + !self.pending.is_empty() + } + + /// Check paths with another function (tests). + pub fn with_check(check: impl Fn(&str) -> Option<Unavailable> + Send + Sync + 'static) -> RecentChecks { + RecentChecks { check: std::sync::Arc::new(check), ..Default::default() } + } + + /// Ask again about answers older than `after` when the window comes back (tests). + #[cfg(test)] + pub fn recheck_after(mut self, after: std::time::Duration) -> RecentChecks { + self.recheck_after = after; + self + } + + /// `path` was just opened (or made): it can be opened. + pub fn opened(&mut self, path: &str) { + self.known.insert(path.to_string(), Answer::local(None)); + } + + /// Opening `path` from the launch window failed. That says nothing about whether it is there + /// (another program may have it open): its answer is dropped, so it is checked again on the next + /// frame. The launch list keeps the open's error on that row for as long as it shows. + pub fn failed(&mut self, path: &str) { + self.known.remove(path); + } + + /// `path` left the list: forget its answer. + pub fn forget(&mut self, path: &str) { + self.known.remove(path); + } +} + +/// Check each of `paths` on a worker of its own (one that is being checked already is left to +/// finish). The activity stack lists each check while it runs (a quick one doesn't show: rows +/// appear after half a second); none can be stopped, as a stat that hangs can't be. +fn start_checks(app: &mut LightcraftApp, paths: Vec<String>) { + for path in paths { + if app.recent_checks.pending.contains_key(&path) { + continue; + } + // asked again about an answer it has (not after a failed open, which dropped it and says why + // on its row) + let recheck = app.recent_checks.known.contains_key(&path); + let check = app.recent_checks.check.clone(); + let p = path.clone(); + let started = crate::tasks::spawn(app, CHECK_LABEL, Some("library"), move || check(&p), { + let path = path.clone(); + move |app, ctx, why| { + app.recent_checks.pending.remove(&path); + let delivered_frame = ctx.cumulative_frame_nr_for(egui::ViewportId::ROOT); + app.recent_checks.known.insert(path, Answer::from_check(why, delivered_frame)); + apply_answers(app); + ctx.request_repaint(); + } + }); + match started { + Ok(()) => { + // a row of the launch list waits for this answer again (a library not found looked for + // again, a window back to the front): without it, the row kept its old answer + if recheck + && let Some(l) = + app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()).and_then(|c| c.libraries.iter_mut().find(|l| l.path == path)) + { + l.checking = true; + l.slow = false; + l.unavailable = None; + } + app.recent_checks.pending.insert(path, web_time::Instant::now()); + } + // no worker thread: say so on the row, and don't try again every frame + Err(e) => { + log::warn!("{e}"); + app.recent_checks.known.insert(path, Answer::local(Some(Unavailable::CheckFailed(e)))); + apply_answers(app); + } + } + } +} + +/// Checks whose worker stopped without answering (it panicked: `tasks::poll` already said so) +/// end as failed, so the row and the menu don't wait for them forever. +fn reap_checks(app: &mut LightcraftApp) { + // (every check has the same label: when none is listed any more, the ones still pending died) + if app.recent_checks.pending.is_empty() || app.tasks.is_running(CHECK_LABEL) { + return; + } + let dead: Vec<String> = app.recent_checks.pending.keys().cloned().collect(); + for path in dead { + app.recent_checks.pending.remove(&path); + let why = Unavailable::CheckFailed(crate::i18n::tr("This library couldn't be checked").to_string()); + app.recent_checks.known.insert(path, Answer::local(Some(why))); + } + apply_answers(app); +} + +/// Put the answers in into the launch list; once none is missing, select as +/// [`LibraryProblem::choose`] does unless the person moved the highlight. +fn apply_answers(app: &mut LightcraftApp) { + let last = app.ui.settings.library_path.clone(); + let checks = &app.recent_checks; + let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) else { return }; + let checking_before = c.libraries.iter().any(|l| l.checking); + for l in &mut c.libraries { + if l.checking && !checks.is_checking(&l.path) && checks.is_known(&l.path) { + l.unavailable = checks.reason(&l.path).map(Unavailable::text); + l.checking = false; + } + } + if checking_before && !c.libraries.iter().any(|l| l.checking) && !c.touched { + c.selected = initial_selection(&c.libraries, &last); + } +} + +/// Why the library at `path` can't be opened (`None`: as far as can be told without opening it). +/// +/// Read-only: the folder and its catalog file are looked at, never locked or written (the list +/// may name libraries on other drives, network shares or sleeping disks). A library another +/// program has open is therefore found when Open is pressed: the open fails and the window says +/// who has it. Runs on a worker: the reason is put into words on the UI thread. +fn unavailable(path: &str) -> Option<Unavailable> { + let dir = Path::new(path); + // only a definite answer marks it missing: a lookup that fails otherwise (a parent folder it may + // not read, a share that timed out) is "couldn't check", and the library can still be opened + match std::fs::metadata(dir) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Some(Unavailable::NotFound), + Err(e) => return Some(Unavailable::LookupFailed(e.to_string())), + Ok(m) if !m.is_dir() => return Some(Unavailable::NotAFolder), + Ok(_) => {} + } + match std::fs::read_dir(dir) { + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Some(Unavailable::Unreadable(e.to_string())), + Err(e) => return Some(Unavailable::LookupFailed(e.to_string())), + Ok(_) => {} + } + // opening would make a new, empty library in it + if !lightcraft_engine::library::is_library(dir) { + return Some(Unavailable::NoLibrary); + } + // the catalog the open reads first: the snapshot, else the log alone (opened to read, not read) + let snapshot = dir.join(lightcraft_catalog::journal::SNAPSHOT); + let catalog = if snapshot.is_file() { snapshot } else { dir.join(lightcraft_catalog::journal::LOG) }; + if let Err(e) = std::fs::File::open(&catalog) { + return Some(Unavailable::CatalogUnreadable(e.to_string())); + } + None +} + /// What the user chose. +#[derive(Clone, Copy, Debug, PartialEq)] enum Choice { Retry, Choose, Temporary, Quit, + /// The launch list: open the selected library. + Open, + /// The launch list: forget the selected library. + Remove, + /// The launch list: make a library (New Library…). + New, + /// The launch list over a library already open: close the window and keep it. + Keep, +} + +/// Whether dropped photos wait for a library ([`hold_dropped`]): none is open and the window +/// asking for one is up (not dismissed by Continue Without Saving). +pub fn holds_drops(app: &LightcraftApp) -> bool { + app.session.library.is_none() && app.library_problem.as_ref().is_some_and(|p| !p.dismissed) +} + +/// Photos dropped on the window while no library is open (the launch list, or the window of a +/// library that failed to open): kept with the command line's files and imported once a library +/// opens, instead of going into the session the next library replaces. Gives the photos back when +/// a library is open or Continue Without Saving was chosen. +pub fn hold_dropped(app: &mut LightcraftApp, ctx: &egui::Context, photos: Vec<String>) -> Option<Vec<String>> { + // called every frame, with nothing dropped most of the time: only a real drop is held (and toasted) + if photos.is_empty() || !holds_drops(app) { + return Some(photos); + } + let problem = app.library_problem.as_mut()?; + problem.pending_import.extend(photos); + app.toast(ctx, crate::i18n::tr("The photos are imported once a library is open")); + None } /// Open `path` (`None`: ask for a folder) through `app.openLibrary`; on success the problem is @@ -64,7 +527,21 @@ fn open(app: &mut LightcraftApp, path: Option<String>) { } } Err(e) => { + // a failed switch reopens the library that was open behind the launch list + let generation = app.session.library.as_ref().map(|_| app.session.library_generation()); + if let Some(path) = path.as_deref() { + app.recent_checks.failed(path); + } if let Some(p) = app.library_problem.as_mut() { + if let Some(c) = p.chooser.as_mut() { + if c.over.is_some() { + c.over = generation; + } + if let Some(l) = c.libraries.iter_mut().find(|l| path.as_deref() == Some(l.path.as_str())) { + l.unavailable = Some(e.clone()); + l.checking = false; + } + } if let Some(path) = path { p.path = path; } @@ -75,11 +552,159 @@ fn open(app: &mut LightcraftApp, path: Option<String>) { } } -/// 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; +/// Clear the problem once a library is open (e.g. through Settings → Open Library…) — another +/// one than the launch list was opened over; offer the list when Alt/Option is held as the window +/// appears; and the list's keys. +pub fn logic(app: &mut LightcraftApp, ctx: &egui::Context) { + if let Some(p) = &app.library_problem + && app.session.library.is_some() + && p.chooser.as_ref().and_then(|c| c.over) != Some(app.session.library_generation()) + { + // files from the command line go into the library opened another way (New Library…, + // File ▸ Open Library…), as through the window's own buttons + let files = app.library_problem.take().map(|p| p.pending_import).unwrap_or_default(); + if !files.is_empty() + && let Err(e) = crate::import::start_paths(app, files) + { + log::warn!("import: {e}"); + } } + alt_at_launch(app, ctx); + reap_checks(app); + // the launch list asks for fresh answers once + let listed: Option<Vec<String>> = + app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()).and_then(|c| { + (!std::mem::replace(&mut c.asked, true)).then(|| c.libraries.iter().filter(|l| l.checking).map(|l| l.path.clone()).collect()) + }); + if let Some(listed) = listed { + for path in &listed { + if !app.recent_checks.is_checking(path) { + app.recent_checks.forget(path); + } + } + start_checks(app, listed); + apply_answers(app); + } + // File ▸ Open Recent Library: paths never checked; libraries not found, every RECHECK_AFTER (a + // drive plugged back in; looking at a path that isn't there wakes nothing); the others when the + // window comes back to the front and their answer is that old. The list as shown (at most + // RECENT_LIBRARIES). + let focused = ctx.input(|i| i.focused); + let regained = focused && !std::mem::replace(&mut app.recent_checks.focused, focused); + let after = app.recent_checks.recheck_after; + // The frame whose UI phase is scheduling now. An answer applied during this frame (by + // `logic_background`'s `tasks::poll`, or a hidden logic tick just before the first visible UI) + // has this number too, so it is shown for at least this whole frame: scheduling it again in + // the same frame would erase it before any completed frame showed it, and a discarded layout + // pass must not change that. Unknown paths have no answer and still start immediately. + let frame = ctx.cumulative_frame_nr_for(egui::ViewportId::ROOT); + let stale: Vec<String> = app + .ui + .settings + .recent_libraries + .iter() + .take(crate::state::RECENT_LIBRARIES) + .filter(|p| { + !app.recent_checks.is_checking(p) + && app.recent_checks.known.get(*p).is_none_or(|a| { + a.delivered_frame != Some(frame) && (regained || matches!(a.why, Some(Unavailable::NotFound))) && a.at.elapsed() > after + }) + }) + .cloned() + .collect(); + start_checks(app, stale); + if app.recent_checks.is_running() { + // rows still checking say so once it takes long + let checks = &app.recent_checks; + if let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) { + for l in c.libraries.iter_mut().filter(|l| l.checking) { + l.slow = checks.checking_since(&l.path).is_some_and(|t| t.elapsed() > SLOW_AFTER); + } + } + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + chooser_keys(app, ctx); +} + +/// Alt/Option held during the first [`ALT_SECONDS`] after the window appears opens the launch list +/// once, over the library that opened (opening another one closes it). Not while files from the +/// command line are importing into that library. +fn alt_at_launch(app: &mut LightcraftApp, ctx: &egui::Context) { + if !app.alt_asks_for_library { + return; + } + let (alt, time) = ctx.input(|i| (i.modifiers.alt, i.time)); + if time > ALT_SECONDS || app.library_problem.is_some() { + app.alt_asks_for_library = false; + return; + } + if !alt { + return; + } + app.alt_asks_for_library = false; + if crate::panels::settings::can_switch_library(app).is_err() { + return; + } + let mut p = LibraryProblem::choose_later(&app.ui.settings); + if let Some(c) = p.chooser.as_mut() { + c.over = app.session.library.as_ref().map(|_| app.session.library_generation()); + } + app.library_problem = Some(p); + ctx.request_repaint(); +} + +/// The launch list on screen (no dialog over it, e.g. New Library's name). +fn chooser_shown(app: &LightcraftApp) -> Option<&Chooser> { + let p = app.library_problem.as_ref().filter(|p| !p.dismissed)?; + p.chooser.as_ref().filter(|_| app.ui.dialog.is_none()) +} + +/// ↑ / ↓ move the selection, Return opens it (unless a button has the keyboard focus: it acts +/// itself), Esc quits — or, over a library already open, keeps it. Taken before the shortcuts. +fn chooser_keys(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(c) = chooser_shown(app) else { return }; + let (n, selected, over) = (c.libraries.len(), c.selected, c.over.is_some()); + let focused = ctx.memory(|m| m.focused().is_some()); + use egui::{Key, Modifiers}; + let (up, down, enter, esc) = ctx.input_mut(|i| { + let up = i.consume_key(Modifiers::NONE, Key::ArrowUp); + let down = i.consume_key(Modifiers::NONE, Key::ArrowDown); + let enter = !focused && i.consume_key(Modifiers::NONE, Key::Enter); + (up, down, enter, i.consume_key(Modifiers::NONE, Key::Escape)) + }); + if (up || down) && n > 0 { + let at = match selected { + Some(s) if up => s.saturating_sub(1), + Some(s) => (s + 1).min(n - 1), + None => 0, + }; + select(app, at); + } + if enter { + act(app, Choice::Open); + } else if esc { + act(app, if over { Choice::Keep } else { Choice::Quit }); + } +} + +/// Highlight row `at` of the launch list. +fn select(app: &mut LightcraftApp, at: usize) { + if let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) + && at < c.libraries.len() + { + c.selected = Some(at); + c.touched = true; + } +} + +/// Dim everything behind the window; it swallows clicks, so nothing there can be used meanwhile. +fn dim(ctx: &egui::Context) { + let screen = ctx.content_rect(); + egui::Area::new(egui::Id::unique("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)); + }); } /// The blocking window (until a choice is made). @@ -88,13 +713,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if problem.dismissed { return; } + if let Some(chooser) = &problem.chooser { + // a dialog it opened (New Library's name) goes first; the list comes back after it + if app.ui.dialog.is_none() { + show_chooser(app, ctx, &problem, chooser); + } + return; + } let t = Tokens::get(ctx); - let screen = ctx.content_rect(); - egui::Area::new(egui::Id::unique("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)); - }); + dim(ctx); 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")) @@ -139,10 +766,50 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { button(ui, "libraryQuit", "Quit", true, Choice::Quit); }); }); + if let Some(c) = choice { + act(app, c); + } +} + +/// Carry out `choice` (a button or key of either window). +fn act(app: &mut LightcraftApp, choice: Choice) { + let Some(problem) = app.library_problem.clone() else { return }; + let chosen = problem.chooser.as_ref().and_then(|c| c.selected.and_then(|i| c.libraries.get(i)).map(|l| (l.clone(), c.over))); match choice { - Some(Choice::Retry) => open(app, Some(problem.path)), - Some(Choice::Choose) => open(app, None), - Some(Choice::Temporary) => { + Choice::Retry => open(app, Some(problem.path)), + Choice::Choose => open(app, None), + Choice::Open => { + let Some((library, over)) = chosen.filter(|(l, _)| l.unavailable.is_none()) else { return }; + // the library already open behind the list: nothing to switch + let open_now = app.session.library.as_ref().filter(|l| l.on_disk).is_some_and(|l| l.dir == Path::new(&library.path)); + if over.is_some() && open_now { + app.library_problem = None; + } else { + open(app, Some(library.path)); + } + } + Choice::Remove => { + let Some((library, _)) = chosen else { return }; + crate::panels::settings::forget_library(&mut app.ui.settings, &library.path); + app.recent_checks.forget(&library.path); + if let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) { + c.libraries.retain(|l| l.path != library.path); + c.selected = c.selected.map(|s| s.min(c.libraries.len().saturating_sub(1))).filter(|_| !c.libraries.is_empty()); + } + } + Choice::New => { + // asks for the name, then the place; the list shows again if that's cancelled + match app.run("app.newLibrary", json!({})) { + Ok(_) => {} + Err(e) => { + if let Some(p) = app.library_problem.as_mut() { + p.error = e; + } + } + } + } + Choice::Keep => app.library_problem = None, + Choice::Temporary => { if let Some(p) = app.library_problem.as_mut() { p.dismissed = true; } @@ -154,11 +821,137 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { log::warn!("import: {e}"); } } - Some(Choice::Quit) => app.ui.quit = true, - None => {} + Choice::Quit => app.ui.quit = true, } } +/// The launch list (issue #1290): the recent libraries, greyed out with the reason when they +/// can't be opened, and Open / Open Another… / New Library… / Remove from List / Quit. +fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryProblem, chooser: &Chooser) { + let t = Tokens::get(ctx); + dim(ctx); + let mut choice = None; + let mut clicked_row = None; + let can_pick = app.services.pick_folder.is_some(); + let open_now = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); + egui::Window::new(crate::i18n::tr("Open a Library")) + .id(egui::Id::unique("library-chooser")) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .frame(frame) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .default_width(520.0) + .show(ctx, |ui| { + // a fixed width: a wrapped label in an auto-sized, centred window would move it every frame + ui.set_width(500.0); + ui.spacing_mut().item_spacing.y = 8.0; + ui.label(RichText::new(crate::i18n::tr("Choose the library to open.")).color(t.text_label)); + if chooser.libraries.is_empty() { + let r = ui.label(RichText::new(crate::i18n::tr("No recent libraries. Open one from a folder, or make a new one.")).color(t.text_dim)); + register(ui.ctx(), "indicator:noRecentLibraries", r.rect); + } + egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for (i, library) in chooser.libraries.iter().enumerate() { + let r = recent_row(ui, &t, i, library, chooser.selected == Some(i), open_now.as_deref() == Some(Path::new(&library.path))); + if r.double_clicked() { + clicked_row = Some((i, true)); + } else if r.clicked() { + clicked_row = Some((i, false)); + } + } + }); + if !problem.error.is_empty() { + ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap()); + } + let selected = chooser.selected.and_then(|i| chooser.libraries.get(i)); + 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); + } + }; + ui.horizontal(|ui| { + button(ui, "libraryForget", "Remove from List", selected.is_some(), Choice::Remove); + ui.label(RichText::new(crate::i18n::tr("The library itself stays where it is.")).size(11.0).color(t.text_dim)); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + button(ui, "libraryOpen", "Open", selected.is_some_and(|l| l.unavailable.is_none()), Choice::Open); + button(ui, "libraryOpenAnother", "Open Another…", can_pick, Choice::Choose); + button(ui, "libraryNew", "New Library…", can_pick, Choice::New); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + button(ui, "libraryQuit", "Quit", true, Choice::Quit); + }); + }); + }); + if let Some((i, open)) = clicked_row { + select(app, i); + if open { + act(app, Choice::Open); + } + } + if let Some(c) = choice { + act(app, c); + } +} + +/// One row of the launch list: the library's name and when it was last opened, the folder it is +/// in, and why it can't be opened (then greyed out). Registered as `libraryRecent:<index>`. +fn recent_row(ui: &mut egui::Ui, t: &Tokens, i: usize, library: &RecentLibrary, selected: bool, open: bool) -> egui::Response { + let dir = Path::new(&library.path); + let name = crate::panels::settings::library_name(dir); + let parent = dir.parent().map(|p| p.display().to_string()).unwrap_or_default(); + let ok = library.unavailable.is_none(); + let (main, dim) = if ok { (t.text, t.text_dim) } else { (t.text_disabled, t.text_disabled) }; + let background = ui.painter().add(egui::Shape::Noop); + let inner = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 1.0; + ui.horizontal(|ui| { + ui.add(egui::Label::new(RichText::new(name).font(t.semibold(12.5)).color(main)).truncate().selectable(false)); + let when = if open { + Some(crate::i18n::tr("Open now").to_string()) + } else { + library + .opened + .as_deref() + .and_then(|iso| iso.get(..10)) + .map(|day| format!("{} {}", crate::i18n::tr("Last opened"), crate::i18n::date_group_label(day, false))) + }; + if let Some(when) = when { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(when).size(11.0).color(dim)).selectable(false)); + }); + } + }); + ui.add(egui::Label::new(RichText::new(parent).size(11.0).color(dim)).truncate().selectable(false)); + if let Some(why) = &library.unavailable { + ui.add(egui::Label::new(RichText::new(why).size(11.0).color(t.caution)).wrap().selectable(false)); + } else if library.checking { + let text = + if library.slow { crate::i18n::tr("Not answering yet: the drive may be asleep or offline") } else { crate::i18n::tr("Checking…") }; + ui.add(egui::Label::new(RichText::new(text).size(11.0).color(dim)).wrap().selectable(false)); + } + }); + let r = ui.interact(inner.response.rect, egui::Id::unique(("library-recent", i)), egui::Sense::click()); + register(ui.ctx(), format!("libraryRecent:{i}"), r.rect); + let fill = if selected { + Some(t.tool_active) + } else if r.hovered() { + Some(t.hover) + } else { + None + }; + if let Some(fill) = fill { + ui.painter().set(background, egui::Shape::rect_filled(r.rect, 4.0, fill)); + } + r +} + /// 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) { diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index 355308b61..db3b8b6d0 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -237,7 +237,12 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option let elided = g.elided; ui.painter().galley(rect.left_center() + vec2(18.0, -g.size().y / 2.0), g, t.text); if fav { - ui.painter().text(rect.right_center() - vec2(4.0, 0.0), egui::Align2::RIGHT_CENTER, "★", t.font(11.0), t.text_dim); + crate::rating::paint_star( + ui.painter(), + egui::Rect::from_center_size(rect.right_center() - vec2(10.0, 0.0), vec2(11.0, 11.0)), + true, + t.text_dim, + ); } if moved && st.highlight == Some(i) { resp.scroll_to_me(None); @@ -522,10 +527,7 @@ pub fn manage_body( for e in list { let sel = selected.contains(&e.name); ui.horizontal(|ui| { - let star = ui.add(egui::Button::new(if e.favorite { "★" } else { "☆" }).frame(false)).on_hover_text(crate::i18n::tr("Favorite")); - if ui.is_rect_visible(star.rect) { - register(ui.ctx(), format!("effectStar:{}", e.name), star.rect); - } + let star = crate::rating::favorite_button(ui, format!("effectStar:{}", e.name), e.favorite, crate::i18n::tr("Favorite")); if star.clicked() { toggle_star = Some((e.name.clone(), !e.favorite)); } diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 91a4e856b..f48a7ff56 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -169,7 +169,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }; if let Some(status) = status { ui.add_space(4.0); - ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap()); + ui.horizontal_top(|ui| { + crate::progress::spinner(ui, 14.0); + ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap()); + }); ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); } // the model download, while its dialog is closed @@ -177,11 +180,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if download.running && app.ui.dialog.is_none() { ui.add_space(4.0); ui.horizontal(|ui| { - let pct = if download.total > 0 { download.done as f64 / download.total as f64 } else { 0.0 }; - let r = ui.add( - egui::ProgressBar::new(pct as f32).desired_width(ui.available_width() - 70.0).text(format!("SAM 3: {} %", (pct * 100.0).floor())), - ); - register(ui.ctx(), "maskSamProgress", r.rect); + // the bar takes what the Details button leaves + let details = ui.spacing().interact_size.x + 30.0; + sam_download_bar(ui, download.done, download.total, (ui.available_width() - details).max(96.0)); if text_button(ui, "maskSamDetails", crate::i18n::tr("Details"), false).clicked() { app.offer_sam_download(None); } @@ -760,6 +761,28 @@ pub(crate) fn start_describe(app: &mut LightcraftApp, op: &str) { app.ui.describe = Some((op.to_string(), String::new())); } +/// The SAM model's download, `width` wide. The label is a percentage once the size is known and +/// just the model's name before, so the bar keeps its height and its accessible name. +pub(crate) fn sam_download_bar(ui: &mut egui::Ui, done: u64, total: u64, width: f32) -> egui::Response { + let fraction = crate::progress::fraction_of(done, total); + let label = match fraction { + Some(f) => format!("SAM 3: {} %", crate::progress::percent(f)), + None => "SAM 3".to_string(), + }; + crate::progress::Progress::new(fraction).label(label).width(width).widget("maskSamProgress").show(ui) +} + +/// Whether the segmenter has work (loading, analyzing, a request, a detail pass running or waiting +/// to start): nothing new starts then. +fn segmenter_working(analyzing: bool, busy: bool, detail_busy: bool, detail_due: bool) -> bool { + analyzing || busy || detail_busy || detail_due +} + +/// Enter in the Describe field is the Select button: it starts nothing while the segmenter works. +fn enter_submits(working: bool, lost_focus: bool, enter_pressed: bool) -> bool { + !working && lost_focus && enter_pressed +} + /// The Describe field: type what to select ("sky", "the red car") and press Return. fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { let Some((op, mut text)) = app.ui.describe.clone() else { return }; @@ -777,7 +800,9 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { let mut submit = false; // the (translated) Select button is placed first, at the right; the field takes what is left ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() { + let seg = &app.session.segmenter; + let working = segmenter_working(seg.analyzing(), seg.busy(), seg.detail_busy(), app.ui.detail_due.is_some()); + if crate::button::Button::new("Select").small().busy(working).widget("button:maskDescribeGo").show(ui).clicked() { submit = true; } let r = ui.add( @@ -789,7 +814,8 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { if !r.has_focus() && !r.lost_focus() && text.is_empty() { r.request_focus(); } - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // Enter is the Select button: nothing starts while the segmenter is working + if enter_submits(working, r.lost_focus(), ui.input(|i| i.key_pressed(egui::Key::Enter))) { submit = true; } }); @@ -897,6 +923,28 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn a_detail_pass_counts_as_the_segmenter_working() { + assert!(segmenter_working(false, false, true, false)); + assert!(segmenter_working(true, false, false, false)); + assert!(segmenter_working(false, true, false, false)); + assert!(!segmenter_working(false, false, false, false)); + } + + #[test] + fn a_pending_detail_pass_counts_as_the_segmenter_working() { + // a new prompt finishing before it starts would replace its target, and that mask never refines + assert!(segmenter_working(false, false, false, true)); + } + + #[test] + fn enter_in_the_describe_field_starts_nothing_while_the_segmenter_works() { + assert!(enter_submits(false, true, true)); + assert!(!enter_submits(true, true, true)); + assert!(!enter_submits(false, false, true)); + assert!(!enter_submits(false, true, false)); + } + /// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label /// room by dropping a column rather than drawing it past its tile. #[test] diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b603e3eb3..19e55391d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,7 @@ pub mod empty_library; pub mod faces; pub mod filterbar; pub mod grid; +pub mod histogram; pub mod keymap; pub mod keyword_list; pub mod keywording; @@ -28,6 +29,7 @@ pub mod presets; pub mod profiles; pub mod right; pub mod rules_editor; +pub mod rules_move; pub mod second; pub mod settings; pub mod slide_layout; diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index fc8eee09c..e34230cfc 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -82,6 +82,8 @@ impl AppliedByPhoto { } } +/// The Presets panel: the Favorites section of starred presets on top (issue #1999), then the +/// groups of built-in and user presets, each row applying its preset on click. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)).show(ui, |ui| { @@ -166,12 +168,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // imported whole is 10 000+ presets, and building every row each frame took seconds. let visible = ui.clip_rect().expand(row_h); for grp in groups.iter() { - let (g, builtin) = (grp.name.clone(), grp.builtin); - let open_id = egui::Id::unique(("preset-group", &g)); + let (g, builtin, favorites) = (grp.name.clone(), grp.builtin, grp.favorites); + // the Favorites section opens and closes apart from a user group named "Favorites" + let open_id = egui::Id::unique(("preset-group", favorites, &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); crate::widgets::announce(&resp, g.to_string()); - register(ui.ctx(), format!("presetGroup:{g}"), r); + // outside the `presetGroup:` ids, which a user group of any name can take + register(ui.ctx(), if favorites { "presetFavorites".to_string() } else { format!("presetGroup:{g}") }, r); paint( ui.painter(), Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), @@ -188,11 +192,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } - crate::widgets::context_menu(&resp, |ui| { - if ui.button(crate::i18n::tr("Export Group…")).clicked() { - let _ = app.run("file.exportPresets", json!({"group": g})); - } - }); + if !favorites { + crate::widgets::context_menu(&resp, |ui| { + if ui.button(crate::i18n::tr("Export Group…")).clicked() { + let _ = app.run("file.exportPresets", json!({"group": g})); + } + }); + } if !open { continue; } @@ -205,7 +211,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); crate::widgets::announce(&resp, name.clone()); - register(ui.ctx(), format!("preset:{pid}"), r); + // a starred preset is listed twice: in Favorites and in its group + register(ui.ctx(), if favorites { format!("presetFavorite:{pid}") } else { format!("preset:{pid}") }, r); let is_last = marked.as_deref() == Some(pid.as_str()); if is_last { register(ui.ctx(), "presetApplied", r); @@ -227,7 +234,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); if fav { - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(16.0, 16.0)), Icon::StarFilled, t.star); + crate::rating::paint_star( + ui.painter(), + Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(16.0, 16.0)), + true, + t.star, + ); } // resting on a preset previews it in the loupe (no history entry) if resp.hovered() @@ -321,6 +333,8 @@ struct Group { builtin: bool, /// Holds a user preset (a Move to Group target). user: bool, + /// The Favorites section on top (issue #1999): the starred presets, each also in its own group. + favorites: bool, items: Vec<usize>, } @@ -335,12 +349,21 @@ fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc<Vec<Group>> { } let mut by_name: BTreeMap<&str, Group> = BTreeMap::new(); for (i, p) in presets.iter().enumerate() { - let g = by_name.entry(&p.group).or_insert_with(|| Group { name: p.group.clone(), builtin: false, user: false, items: Vec::new() }); + let g = by_name.entry(&p.group).or_insert_with(|| Group { + name: p.group.clone(), + builtin: false, + user: false, + favorites: false, + items: Vec::new(), + }); g.builtin |= p.builtin; g.user |= !p.builtin; g.items.push(i); } - let groups = Arc::new(by_name.into_values().collect::<Vec<_>>()); + // starred presets first, in their own section, as Profiles does + let starred: Vec<usize> = presets.iter().enumerate().filter(|(_, p)| p.favorite).map(|(i, _)| i).collect(); + let favorites = (!starred.is_empty()).then(|| Group { name: "Favorites".into(), builtin: true, user: false, favorites: true, items: starred }); + let groups = Arc::new(favorites.into_iter().chain(by_name.into_values()).collect::<Vec<_>>()); ctx.data_mut(|d| d.insert_temp(id, (presets.generation(), groups.clone()))); groups } diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 5bb6c341a..9604493c8 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -182,7 +182,7 @@ fn cell_ui( let over_star = resp.hover_pos().is_some_and(|q| star.contains(q)); if fav || resp.hovered() { painter.circle_filled(star.center(), 10.0, Color32::from_black_alpha(110)); - paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE }); + crate::rating::paint_star(painter, star.shrink(4.0), fav, if fav || over_star { t.star } else { Color32::WHITE }); } if resp.hovered() && !applied && !over_star { app.hover_preview = diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index af61212ca..2534fb424 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -642,7 +642,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label)); } ui.add_space(8.0); - if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner { + if let Some(r) = ui.horizontal(|ui| crate::rating::Rating::new("info", p.rating).show(ui)).inner { let mut params = json!({"rating": r}); crate::panels::compare::target_active(app, &mut params); let _ = app.run("photo.rate", params); @@ -866,7 +866,7 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { } }); } - // where the camera focused, and how (View ▸ Show Focus Point draws it): only when known; the + // where the camera focused, and how (View ▸ Focus Point draws it): only when known; the // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits if let Some(text) = focus_text(m.focus.as_ref()) { ui.add_space(3.0); diff --git a/crates/ui-egui/src/panels/rules_move.rs b/crates/ui-egui/src/panels/rules_move.rs new file mode 100644 index 000000000..de5ccc2ef --- /dev/null +++ b/crates/ui-egui/src/panels/rules_move.rs @@ -0,0 +1,413 @@ +//! The move engine behind reordering rules and groups in the smart-album editor +//! ([`super::rules_editor`]): pure functions on a [`RuleSet`], and nothing else. A row is a path of +//! positions from the top list (`[1, 0]`: the first rule of the second rule's group); a place to +//! drop is a path too: the group's path and the position among its rules, counted before the row +//! leaves its old place. +//! +//! [`judge`], [`move_node`] and [`key_dest`] never trust a path: one that leads nowhere changes +//! nothing. A group left empty by a move goes away, and groups nest at most [`MAX_GROUP_DEPTH`] deep. + +use lightcraft_catalog::{Rule, RuleSet}; + +/// Groups nest at most this deep: the editor offers a group only in a list whose depth is below it +/// (the top list is depth 0). +pub const MAX_GROUP_DEPTH: usize = 3; + +/// Where the arrow keys send a row (↑ ↓ ← →). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dir { + Up, + Down, + /// ← : out of its group. + Out, + /// → : into the group next to it. + In, +} + +/// Why a move is refused (shown, not just ignored). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// A group dropped inside itself or something in it. + IntoItself, + /// A group dropped where its rules would nest deeper than [`MAX_GROUP_DEPTH`]. + TooDeep, +} + +/// What a move of one row to a place would do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Verdict { + Moves, + /// Where it already is, or a place or row that isn't there. + Nothing, + Refused(Refusal), +} + +/// The group (or the top list) at `path`. +fn list_at<'a>(rs: &'a RuleSet, path: &[usize]) -> Option<&'a RuleSet> { + let mut list = rs; + for &i in path { + match list.rules.get(i)? { + Rule::Group { group } => list = group, + Rule::Field { .. } => return None, + } + } + Some(list) +} + +fn list_at_mut<'a>(rs: &'a mut RuleSet, path: &[usize]) -> Option<&'a mut RuleSet> { + let mut list = rs; + for &i in path { + match list.rules.get_mut(i)? { + Rule::Group { group } => list = group, + Rule::Field { .. } => return None, + } + } + Some(list) +} + +/// The rule or group at `path`. +fn node_at<'a>(rs: &'a RuleSet, path: &[usize]) -> Option<&'a Rule> { + let (last, parent) = path.split_last()?; + list_at(rs, parent)?.rules.get(*last) +} + +/// How many lists a rule brings: none for a field rule, one more than its deepest group for a group. +fn height(rule: &Rule) -> usize { + match rule { + Rule::Field { .. } => 0, + Rule::Group { group } => 1 + group.rules.iter().map(height).max().unwrap_or(0), + } +} + +fn remove_at(rs: &mut RuleSet, path: &[usize]) -> Option<Rule> { + let (last, parent) = path.split_last()?; + let list = list_at_mut(rs, parent)?; + (*last < list.rules.len()).then(|| list.rules.remove(*last)) +} + +fn insert_at(rs: &mut RuleSet, path: &[usize], rule: Rule) -> Option<()> { + let (last, parent) = path.split_last()?; + let list = list_at_mut(rs, parent)?; + if *last > list.rules.len() { + return None; + } + list.rules.insert(*last, rule); + Some(()) +} + +/// `path` as it reads once the row at `removed` is gone: a later sibling's position drops by one. +/// `false` when `path` runs through the removed row itself. +fn shift_after_removal(path: &mut [usize], removed: &[usize]) -> bool { + let Some((last, parent)) = removed.split_last() else { return true }; + if path.len() <= parent.len() || !path.starts_with(parent) { + return true; + } + let through = path.len() > removed.len(); + let Some(at) = path.get_mut(parent.len()) else { return true }; + if *at > *last { + *at -= 1; + true + } else { + *at != *last || !through + } +} + +/// What moving the row at `from` to the place `dest` would do. +pub fn judge(rs: &RuleSet, from: &[usize], dest: &[usize]) -> Verdict { + let (Some((from_last, from_parent)), Some((dest_last, dest_parent))) = (from.split_last(), dest.split_last()) else { + return Verdict::Nothing; + }; + let Some(node) = node_at(rs, from) else { return Verdict::Nothing }; + if matches!(node, Rule::Group { .. }) && dest_parent.starts_with(from) { + return Verdict::Refused(Refusal::IntoItself); + } + let Some(list) = list_at(rs, dest_parent) else { return Verdict::Nothing }; + if *dest_last > list.rules.len() { + return Verdict::Nothing; + } + // before itself, or after itself: the same place + if dest_parent == from_parent && (dest_last == from_last || *dest_last == from_last.saturating_add(1)) { + return Verdict::Nothing; + } + // a group may not end up deeper than the editor offers, but one that is already deeper (rules + // written by an agent) can still be moved about at its own depth or higher + let h = height(node); + let (now, then) = (from_parent.len() + h, dest_parent.len() + h); + if h > 0 && then > MAX_GROUP_DEPTH && then > now { + return Verdict::Refused(Refusal::TooDeep); + } + Verdict::Moves +} + +/// Moves the row at `from` to the place `dest` (see the module doc), if [`judge`] allows it, and +/// returns where the row is now. Groups it leaves empty go away. All or nothing: `rs` is only +/// replaced by a finished result. +pub fn move_node(rs: &mut RuleSet, from: &[usize], dest: &[usize]) -> Option<Vec<usize>> { + if judge(rs, from, dest) != Verdict::Moves { + return None; + } + let mut next = rs.clone(); + let node = remove_at(&mut next, from)?; + let mut dest = dest.to_vec(); + if !shift_after_removal(&mut dest, from) { + return None; + } + let mut chain = from.get(..from.len().checked_sub(1)?)?.to_vec(); + // (a group the row is going into stays, however empty it is for now) + while !chain.is_empty() && !(dest.len() > chain.len() && dest.starts_with(&chain)) && list_at(&next, &chain).is_some_and(|l| l.rules.is_empty()) { + remove_at(&mut next, &chain)?; + if !shift_after_removal(&mut dest, &chain) { + return None; + } + chain.pop(); + } + insert_at(&mut next, &dest, node)?; + *rs = next; + Some(dest) +} + +/// The place an arrow key sends the row at `from` to, if there is one (the move may still be +/// refused: [`judge`]). +pub fn key_dest(rs: &RuleSet, from: &[usize], dir: Dir) -> Option<Vec<usize>> { + let (&i, parent) = from.split_last()?; + let list = list_at(rs, parent)?; + // a row that isn't there has no place to go (and `i` is then below `len`, so `i + 1` can't overflow) + list.rules.get(i)?; + let at = |n: usize| parent.iter().copied().chain([n]).collect::<Vec<usize>>(); + match dir { + Dir::Up if i > 0 => Some(at(i - 1)), + // the first of a group: out, to just before the group + Dir::Up => (!parent.is_empty()).then(|| parent.to_vec()), + Dir::Down if i + 1 < list.rules.len() => Some(at(i + 2)), + // the last of a group, and ←: out, to just after the group + Dir::Down | Dir::Out => { + let (&g, outer) = parent.split_last()?; + Some(outer.iter().copied().chain([g + 1]).collect()) + } + Dir::In => { + let above = i.checked_sub(1).and_then(|a| match list.rules.get(a)? { + Rule::Group { group } => Some(at(a).into_iter().chain([group.rules.len()]).collect::<Vec<usize>>()), + Rule::Field { .. } => None, + }); + above.or_else(|| match list.rules.get(i + 1)? { + Rule::Group { .. } => Some(at(i + 1).into_iter().chain([0]).collect()), + Rule::Field { .. } => None, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn rs(v: Value) -> RuleSet { + serde_json::from_value(v).unwrap() + } + + /// Each rule is named by its rating, each group by `g` and what is in it: `1 2 (3 4) 5`. + fn shape(rs: &RuleSet) -> String { + let parts: Vec<String> = rs + .rules + .iter() + .map(|r| match r { + Rule::Field { value, .. } => value.to_string(), + Rule::Group { group } => format!("({})", shape(group)), + }) + .collect(); + parts.join(" ") + } + + fn rule(n: u32) -> Value { + json!({"field": "rating", "op": "is", "value": n}) + } + + fn group(rules: Vec<Value>) -> Value { + json!({"group": {"match": "any", "rules": rules}}) + } + + fn set(rules: Vec<Value>) -> RuleSet { + rs(json!({"rules": rules})) + } + + fn moved(rules: Vec<Value>, from: &[usize], dest: &[usize]) -> (String, Option<Vec<usize>>) { + let mut r = set(rules); + let at = move_node(&mut r, from, dest); + (shape(&r), at) + } + + #[test] + fn a_rule_moves_down_and_up_in_its_list() { + let list = || vec![rule(1), rule(2), rule(3), rule(4)]; + assert_eq!(moved(list(), &[0], &[3]), ("2 3 1 4".into(), Some(vec![2])), "before the fourth"); + assert_eq!(moved(list(), &[0], &[4]), ("2 3 4 1".into(), Some(vec![3])), "to the end"); + assert_eq!(moved(list(), &[3], &[0]), ("4 1 2 3".into(), Some(vec![0])), "to the top"); + assert_eq!(moved(list(), &[2], &[1]), ("1 3 2 4".into(), Some(vec![1]))); + } + + #[test] + fn a_move_to_where_the_row_already_is_changes_nothing() { + let list = || vec![rule(1), rule(2), rule(3)]; + for dest in [[1], [2]] { + assert_eq!(judge(&set(list()), &[1], &dest), Verdict::Nothing); + assert_eq!(moved(list(), &[1], &dest), ("1 2 3".into(), None)); + } + } + + #[test] + fn a_rule_moves_into_and_out_of_a_group() { + let list = || vec![rule(1), group(vec![rule(2), rule(3)]), rule(4)]; + assert_eq!(moved(list(), &[0], &[1, 1]), ("(2 1 3) 4".into(), Some(vec![0, 1])), "into the group, between its rules"); + assert_eq!(moved(list(), &[2], &[1, 2]), ("1 (2 3 4)".into(), Some(vec![1, 2])), "into its end"); + assert_eq!(moved(list(), &[1, 0], &[0]), ("2 1 (3) 4".into(), Some(vec![0])), "out, to the top"); + assert_eq!(moved(list(), &[1, 1], &[3]), ("1 (2) 4 3".into(), Some(vec![3])), "out, to the end"); + } + + #[test] + fn a_group_left_empty_goes_away() { + let list = || vec![rule(1), group(vec![rule(2)]), rule(3)]; + assert_eq!(moved(list(), &[1, 0], &[0]), ("2 1 3".into(), Some(vec![0])), "the group's only rule goes before it"); + assert_eq!(moved(list(), &[1, 0], &[3]), ("1 3 2".into(), Some(vec![2])), "after everything"); + assert_eq!(moved(list(), &[1, 0], &[1]), ("1 2 3".into(), Some(vec![1])), "to the group's own place"); + // the emptied group is inside another: both go + let deep = vec![rule(1), group(vec![group(vec![rule(2)])]), rule(3)]; + assert_eq!(moved(deep, &[1, 0, 0], &[0]), ("2 1 3".into(), Some(vec![0]))); + // into a group, and the old group is gone: the places after it shift + let two = vec![group(vec![rule(1)]), group(vec![rule(2)])]; + assert_eq!(moved(two, &[0, 0], &[1, 1]), ("(2 1)".into(), Some(vec![0, 1]))); + } + + #[test] + fn a_group_moves_whole_and_never_into_itself() { + let list = || vec![group(vec![rule(1), rule(2)]), rule(3), group(vec![rule(4)])]; + assert_eq!(moved(list(), &[0], &[2]), ("3 (1 2) (4)".into(), Some(vec![1]))); + assert_eq!(moved(list(), &[0], &[3]), ("3 (4) (1 2)".into(), Some(vec![2])), "to the end"); + assert_eq!(moved(list(), &[0], &[2, 1]), ("3 (4 (1 2))".into(), Some(vec![1, 1])), "into the other group"); + for dest in [vec![0, 0], vec![0, 1], vec![0, 2]] { + assert_eq!(judge(&set(list()), &[0], &dest), Verdict::Refused(Refusal::IntoItself), "{dest:?}"); + assert_eq!(moved(list(), &[0], &dest), ("(1 2) 3 (4)".into(), None)); + } + let nested = || vec![group(vec![rule(1), group(vec![rule(2)])])]; + assert_eq!(judge(&set(nested()), &[0], &[0, 1, 0]), Verdict::Refused(Refusal::IntoItself), "inside what is inside it"); + } + + #[test] + fn groups_stay_within_the_depth_the_editor_offers() { + // a group holding a group holding a group: three lists below the top one + let tall = group(vec![group(vec![group(vec![rule(1)])])]); + let list = || vec![tall.clone(), group(vec![rule(2)])]; + // into the second group it would be four deep + assert_eq!(judge(&set(list()), &[0], &[1, 0]), Verdict::Refused(Refusal::TooDeep)); + assert_eq!(moved(list(), &[0], &[1, 0]).1, None); + // but beside it is fine, and so is a rule or a small group at any depth + assert_eq!(judge(&set(list()), &[0], &[2]), Verdict::Moves); + let flat = vec![rule(1), group(vec![group(vec![group(vec![rule(2)])])])]; + assert_eq!(judge(&set(flat.clone()), &[0], &[1, 0, 0, 0]), Verdict::Moves, "a rule can go into the deepest list"); + let small = vec![group(vec![rule(1)]), group(vec![group(vec![group(vec![rule(2)])])])]; + assert_eq!(judge(&set(small.clone()), &[0], &[1, 0, 0, 0]), Verdict::Refused(Refusal::TooDeep), "a group can't"); + assert_eq!(judge(&set(small), &[0], &[1, 0, 0]), Verdict::Moves, "one level up it can"); + } + + #[test] + fn a_group_that_is_already_too_deep_can_still_be_reordered() { + let too_deep = group(vec![group(vec![group(vec![group(vec![rule(1)]), group(vec![rule(2)])])])]); + let r = set(vec![too_deep]); + // the two innermost groups swap places: same depth, allowed + assert_eq!(judge(&r, &[0, 0, 0, 0], &[0, 0, 0, 2]), Verdict::Moves); + // moving one of them deeper is not + assert_eq!(judge(&r, &[0, 0, 0, 0], &[0, 0, 0, 1, 0]), Verdict::Refused(Refusal::TooDeep)); + } + + #[test] + fn paths_that_lead_nowhere_change_nothing() { + let r = || set(vec![rule(1), group(vec![rule(2)])]); + for (from, dest) in [ + (vec![], vec![0]), + (vec![0], vec![]), + (vec![5], vec![0]), + (vec![0], vec![9]), + (vec![0], vec![0, 0]), + (vec![0], vec![5, 0]), + (vec![1, 4], vec![0]), + (vec![usize::MAX], vec![0]), + (vec![0], vec![usize::MAX]), + (vec![1], vec![1, usize::MAX]), + ] { + let mut x = r(); + assert_eq!(move_node(&mut x, &from, &dest), None, "{from:?} -> {dest:?}"); + assert_eq!(x, r()); + } + } + + /// Given a path whose last step is past the end of its list (`usize::MAX` too), when an arrow + /// key asks where the row goes, then there is no place, and no index arithmetic overflows. + #[test] + fn arrow_keys_on_a_path_that_leads_nowhere_find_no_place() { + let r = set(vec![rule(1), group(vec![rule(2)])]); + for from in [vec![2], vec![5], vec![usize::MAX], vec![1, 1], vec![1, usize::MAX]] { + for dir in [Dir::Up, Dir::Down, Dir::Out, Dir::In] { + assert_eq!(key_dest(&r, &from, dir), None, "{from:?} {dir:?}"); + } + } + } + + #[test] + fn arrow_keys_move_a_row_one_place() { + let list = || vec![rule(1), group(vec![rule(2), rule(3)]), rule(4)]; + let key = |from: &[usize], dir| { + let mut r = set(list()); + let at = key_dest(&r, from, dir).and_then(|d| move_node(&mut r, from, &d)); + (shape(&r), at) + }; + assert_eq!(key(&[0], Dir::Down), ("(2 3) 1 4".into(), Some(vec![1])), "a group is passed, not entered"); + assert_eq!(key(&[2], Dir::Up), ("1 4 (2 3)".into(), Some(vec![1]))); + assert_eq!(key(&[0], Dir::Up), ("1 (2 3) 4".into(), None), "the top stays"); + assert_eq!(key(&[2], Dir::Down), ("1 (2 3) 4".into(), None), "the end stays"); + // at a group's ends, ↑ ↓ carry the row out of it + assert_eq!(key(&[1, 0], Dir::Up), ("1 2 (3) 4".into(), Some(vec![1]))); + assert_eq!(key(&[1, 1], Dir::Down), ("1 (2) 3 4".into(), Some(vec![2]))); + assert_eq!(key(&[1, 0], Dir::Down), ("1 (3 2) 4".into(), Some(vec![1, 1])), "inside, one place down"); + // ← : out, just after the group; at the top level nothing + assert_eq!(key(&[1, 0], Dir::Out), ("1 (3) 2 4".into(), Some(vec![2]))); + assert_eq!(key(&[0], Dir::Out), ("1 (2 3) 4".into(), None)); + // → : into the group above (its end) or below (its start) + assert_eq!(key(&[2], Dir::In), ("1 (2 3 4)".into(), Some(vec![1, 2]))); + assert_eq!(key(&[0], Dir::In), ("(1 2 3) 4".into(), Some(vec![0, 0]))); + let lone = || vec![rule(1), rule(2)]; + let mut r = set(lone()); + assert_eq!(key_dest(&r, &[0], Dir::In), None, "no group beside it"); + assert_eq!(move_node(&mut r, &[0], &[0]), None); + } + + #[test] + fn a_rows_only_rule_going_out_takes_its_group_with_it() { + let list = || vec![group(vec![rule(1)]), rule(2)]; + let mut r = set(list()); + let at = key_dest(&r, &[0, 0], Dir::Down).and_then(|d| move_node(&mut r, &[0, 0], &d)); + assert_eq!((shape(&r), at), ("1 2".into(), Some(vec![0])), "the group would be empty"); + let mut r = set(list()); + let at = key_dest(&r, &[0, 0], Dir::Out).and_then(|d| move_node(&mut r, &[0, 0], &d)); + assert_eq!((shape(&r), at), ("1 2".into(), Some(vec![0])), "← too: just after a group that is gone"); + } + + #[test] + fn a_row_leaving_nested_groups_may_leave_the_group_it_is_going_to_empty() { + let mut r = set(vec![group(vec![group(vec![rule(1)])])]); + assert_eq!(move_node(&mut r, &[0, 0, 0], &[0, 0]), Some(vec![0, 0])); + assert_eq!(shape(&r), "(1)"); + let mut r = set(vec![group(vec![group(vec![rule(1)])])]); + let dest = key_dest(&r, &[0, 0, 0], Dir::Out).unwrap(); + assert!(move_node(&mut r, &[0, 0, 0], &dest).is_some()); + assert_eq!(shape(&r), "(1)"); + } + + #[test] + fn arrow_keys_respect_the_depth_limit() { + let tall = group(vec![group(vec![group(vec![rule(1)])])]); + let r = set(vec![tall.clone(), group(vec![rule(2)])]); + // → would put it in the group below: four deep + let dest = key_dest(&r, &[0], Dir::In).unwrap(); + assert_eq!(judge(&r, &[0], &dest), Verdict::Refused(Refusal::TooDeep)); + } +} diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 51f5e778a..5a462dbbc 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -3,7 +3,8 @@ //! //! Changes apply immediately (no OK/Cancel). Where they are stored: //! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, -//! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in +//! preview size, filmstrip/grid badges, last library, monitor profile, whether Import Lightroom +//! Catalog… asks where a catalog goes) and Auto Advance live in //! the UI state, //! saved by the host in its config folder (`ui.json`); //! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow @@ -36,12 +37,52 @@ const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192]; const LABEL_W: f32 = 150.0; -/// The dialog body for `tab` (the tab bar switches `tab`). -pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { +/// The page of `tab` (the tab bar in [`window`] switches `tab`). +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &str) { let t = Tokens::get(ui.ctx()); - ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0)); + match tab { + "import" => import_tab(app, ui, &t), + "performance" => performance_tab(app, ui, &t), + "display" => display_tab(app, ui, &t), + "interface" => interface_tab(app, ui, &t), + "slideshow" => slideshow_tab(app, ui, &t, true), + "faces" => super::faces::settings_tab(app, ui, &t), + "denoise" => super::denoise::settings_tab(app, ui, &t), + _ => general_tab(app, ui, &t), + } +} + +/// The widest the Settings window's content gets, and the tallest its pages get: the window is +/// one size on every tab, so it stays where it is as the tabs are gone through. +const PAGE_SIZE: egui::Vec2 = egui::vec2(560.0, 640.0); + +/// The least room in which a page scrolls by itself. In a window with less (far below the +/// desktop minimum), the tabs and the page scroll together in the dialog's own scroll area. +const PAGE_MIN_HEIGHT: f32 = 120.0; + +/// The share of the page's room the pinned slide preview of the Slideshow tab takes, when the page +/// has more room than it needs to keep [`PAGE_MIN_HEIGHT`] for itself. +const PINNED_PREVIEW_SHARE: f32 = 0.4; + +/// What the preview's heading, the one-line hint under the slide, its Photo / Intro / Ending +/// choice and the rule under it take around the slide (at the default spacing, about 82 pt). +const PREVIEW_CHROME: f32 = 82.0; + +/// The Settings window's content: the tab bar, which stays put, above the page of `tab` +/// ([`body`]), which scrolls when it is taller than the room for it, with a scroll handle that +/// shows without the pointer on it. `max_height` is the room the app window leaves for both: the +/// two together never take more, so the scroll area every dialog is drawn in has nothing to +/// scroll (but for a window with less than [`PAGE_MIN_HEIGHT`] for the page, where that area +/// scrolls the two together). Every page gets the same room whatever it holds (but for the pinned +/// preview of Slideshow), and each keeps a scroll position of its own. Nothing here is wider than the window from 640 pt of app window +/// up: a row's controls wrap ([`row`]) and paths are shortened ([`path_label`]). +/// On Slideshow the slide preview is pinned between the tab bar and the page (where the room +/// allows), and comes off that page's room. +pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_height: f32) { + ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, PAGE_SIZE.x)); + let top = ui.cursor().top(); ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; + ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); for (id, label) in TABS { if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { *tab = id.to_string(); @@ -49,17 +90,99 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { } }); ui.separator(); - ui.add_space(2.0); - match tab.as_str() { - "import" => import_tab(app, ui, &t), - "performance" => performance_tab(app, ui, &t), - "display" => display_tab(app, ui, &t), - "interface" => interface_tab(app, ui, &t), - "slideshow" => slideshow_tab(app, ui, &t), - "faces" => super::faces::settings_tab(app, ui, &t), - "denoise" => super::denoise::settings_tab(app, ui, &t), - _ => general_tab(app, ui, &t), + // (a point less than the room left, so rounding never makes the dialog's own scroll area scroll) + let room = max_height - (ui.cursor().top() - top) - 1.0; + if room < PAGE_MIN_HEIGHT { + body(app, ui, tab.as_str()); + register(ui.ctx(), "settings:page", ui.min_rect()); + return; } + let mut height = room.min(PAGE_SIZE.y); + // The slide preview stays above the page on Slideshow: hovering a template in the Template + // Browser previews it there, so it has to stay in view however far down the templates are + // scrolled to. With its heading and choice it takes [`PINNED_PREVIEW_SHARE`] of the room + // (at least the least tall slide); where that leaves less than [`PAGE_MIN_HEIGHT`] for the + // page, it scrolls with the page instead (as in the short window above). + let mut pinned = false; + if tab == "slideshow" { + let slide_h = (height * PINNED_PREVIEW_SHARE - PREVIEW_CHROME).max(super::slide_layout::PREVIEW_MIN_H); + if height - (slide_h + PREVIEW_CHROME) >= PAGE_MIN_HEIGHT { + let before = ui.cursor().top(); + super::slide_layout::preview(app, ui, &Tokens::get(ui.ctx()), slide_h); + ui.add_space(2.0); + ui.separator(); + height = (height - (ui.cursor().top() - before)).max(PAGE_MIN_HEIGHT); + pinned = true; + } + } + // A page that goes on below shows its scroll handle all the time: without it, a page a line + // or two too long looks cut off, not scrollable. + ui.spacing_mut().scroll.dormant_handle_opacity = 0.6; + let page = egui::ScrollArea::vertical() + .id_salt(("settings-page", tab.as_str())) + .max_height(height) + .min_scrolled_height(height) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add_space(2.0); + if pinned { + slideshow_tab(app, ui, &Tokens::get(ui.ctx()), false); + } else { + body(app, ui, tab.as_str()); + } + // (what the page took: wider than the room for it would mean a control is cut off) + register(ui.ctx(), "settings:page", ui.min_rect()); + }); + register(ui.ctx(), "settings:pageRoom", page.inner_rect); +} + +/// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after +/// either kind of separator, so that its end stays (`…/Pictures/Library`, +/// `…\Pictures\Library`): the end says which folder it is. A last folder too long by itself +/// loses its end ([`crate::widgets::elide_head`]). +fn elide_path(path: &str, max: f32, width: impl Fn(&str) -> f32) -> String { + if width(path) <= max { + return path.to_string(); + } + // (a separator at the very end is not where a name starts: `…/` alone would say nothing) + let end = path.trim_end_matches(['/', '\\']).len(); + let mut rest = path; + while let Some(i) = rest.find(['/', '\\']).filter(|i| path.len() - rest.len() + i + 1 < end) { + let separator = if rest.get(i..).is_some_and(|r| r.starts_with('\\')) { '\\' } else { '/' }; + rest = rest.get(i + 1..).unwrap_or(""); + let shorter = format!("…{separator}{rest}"); + if width(&shorter) <= max { + return shorter; + } + } + let name = rest.trim_end_matches(['/', '\\']); + // (nothing but separators: never an empty label) + if name.is_empty() { "…".to_string() } else { crate::widgets::elide_head(name, max, width) } +} + +/// A folder or file path on one line in the room left in its row (`text:{id}`): shortened from +/// the front when it is too long ([`elide_path`]), with all of it in a tooltip then. `after` is +/// the width of what follows it on the line (a button, a note) and its spacing: the path leaves +/// that room, so those never wrap onto a second line. +fn path_label(ui: &mut egui::Ui, id: &str, path: &str, font: egui::FontId, color: egui::Color32, after: f32) { + let shown = + elide_path(path, (ui.available_width() - after).max(0.0), |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x); + // (`truncate` is the last resort, for a room narrower than "…") + let r = ui.add(egui::Label::new(RichText::new(&shown).font(font).color(color)).truncate().show_tooltip_when_elided(false)); + register(ui.ctx(), format!("text:{id}"), r.rect); + if shown != path { + r.on_hover_text(path); + } +} + +/// The width of `text` in the style `style`, and the gap before it: what a label takes in a row. +fn label_width(ui: &egui::Ui, text: &str, style: egui::TextStyle) -> f32 { + ui.painter().layout_no_wrap(text.to_string(), style.resolve(ui.style()), egui::Color32::WHITE).size().x + ui.spacing().item_spacing.x +} + +/// The same for a button, which has padding either side of its text. +fn button_width(ui: &egui::Ui, text: &str) -> f32 { + label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x } pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { @@ -71,9 +194,17 @@ pub(super) fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnc 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(crate::i18n::tr(label)).color(t.text_label)); + // (a label longer than its column wraps in it; it does not push the controls along) + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(label)).color(t.text_label)).wrap()); }); - add(ui) + // The controls wrap in the room beside the label, each further line starting under the + // first control, so a row with many choices grows down and never widens the window. + let wrapped = egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), wrapped, |ui| { + ui.spacing_mut().item_spacing.y = 4.0; + add(ui) + }) + .inner }) .inner } @@ -119,7 +250,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { None => crate::i18n::tr("In memory — nothing is saved").to_string(), }; row(ui, t, crate::i18n::tr("Location"), |ui| { - ui.label(RichText::new(location).color(t.text)); + path_label(ui, "settingsLocation", &location, egui::TextStyle::Body.resolve(ui.style()), t.text, 0.0); }); row(ui, t, "", |ui| { let can = app.services.pick_folder.is_some(); @@ -137,12 +268,36 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { hint(ui, t, crate::i18n::tr("not available here")); } }); + // where libraries are folders to choose from (not in the browser, which keeps one) + if app.services.pick_folder.is_some() { + use crate::state::LaunchLibrary; + row(ui, t, crate::i18n::tr("At launch"), |ui| { + choices( + ui, + "settingsAtLaunch", + &[(LaunchLibrary::Last, "Open the last library"), (LaunchLibrary::Ask, "Ask me")], + &mut app.ui.settings.at_launch, + ); + }); + hint(ui, t, crate::i18n::tr("Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.")); + } + // File ▸ Import Lightroom Catalog… (#2672): the window that asks where a catalog goes, or not + ui.label(RichText::new(crate::i18n::tr("When importing a Lightroom catalog")).color(t.text_label)); + row(ui, t, "", |ui| { + use crate::state::LightroomImport; + choices( + ui, + "settingsLightroomImport", + &[(LightroomImport::Ask, "Ask where to import it"), (LightroomImport::AlwaysMerge, "Always merge into the open library")], + &mut app.ui.settings.lightroom_import, + ); + }); heading(ui, t, crate::i18n::tr("Startup")); row(ui, t, crate::i18n::tr("Open in"), |ui| { choices( ui, "settingsStartup", - &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail")], + &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail View")], &mut app.ui.settings.startup_view, ); }); @@ -150,6 +305,8 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { 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"); check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); + heading(ui, t, crate::i18n::tr("Dialogs")); + check(ui, "settings.closeDialogsOutside", &mut app.ui.settings.close_dialogs_outside, "Close a dialog by clicking outside it"); heading(ui, t, crate::i18n::tr("External Editing")); row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) @@ -369,6 +526,8 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); if !cfg!(target_arch = "wasm32") { + heading(ui, t, crate::i18n::tr("Dropped Files")); + check(ui, "settings.importDropped", &mut app.ui.settings.import_dropped_directly, "Import dropped files without review"); heading(ui, t, crate::i18n::tr("Cameras and Cards")); let mut on_card = d.show_on_card; if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") { @@ -377,7 +536,17 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { 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(|| crate::i18n::tr("Off").into())).color(t.text)); + match &d.auto_folder { + Some(folder) => path_label( + ui, + "settingsAutoFolder", + folder, + egui::TextStyle::Body.resolve(ui.style()), + t.text, + button_width(ui, crate::i18n::tr("Choose…")) + button_width(ui, crate::i18n::tr("Turn Off")), + ), + None => drop(ui.label(RichText::new(crate::i18n::tr("Off")).color(t.text))), + } let can = app.services.pick_folder.is_some(); let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…"))); register(ui.ctx(), "button:settingsAutoFolder", r.rect); @@ -513,8 +682,18 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let available = loc["available"].as_bool().unwrap_or(true); let custom = loc["custom"].as_bool().unwrap_or(false); 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); + path_label( + ui, + "settingsSmartFolder", + &path, + egui::TextStyle::Body.resolve(ui.style()), + if available { t.text } else { t.reject }, + if custom { 0.0 } else { label_width(ui, "(library default)", egui::TextStyle::Body) }, + ); + if !custom { + let r = ui.label(RichText::new("(library default)").color(t.text)); + register(ui.ctx(), "text:settingsSmartDefault", r.rect); + } }); let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0)); if available { @@ -630,7 +809,7 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let info = d.describe(); if !screen { row(ui, t, crate::i18n::tr("File"), |ui| { - ui.label(RichText::new(&d.path).size(11.0).color(t.text_dim)); + path_label(ui, "settingsDisplayFile", &d.path, egui::FontId::proportional(11.0), t.text_dim, 0.0); }); } row(ui, t, crate::i18n::tr("Type"), |ui| { @@ -684,7 +863,7 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { t, crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."), ); - heading(ui, t, crate::i18n::tr("Detail")); + heading(ui, t, crate::i18n::tr("Detail View")); check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); row(ui, t, crate::i18n::tr("Info overlay"), |ui| { use crate::state::InfoOverlay as I; @@ -755,44 +934,32 @@ fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &s } } -/// Width of the Slideshow tab's settings column (the slide preview is beside it). -const SLIDESHOW_COLUMN_W: f32 = 480.0; - -fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - // the settings scroll on the left, the composed slide stays in view on the right - let top_down = egui::Layout::top_down(egui::Align::Min); - // as tall as the window allows (the dialog's title, tabs and Close button take ~260 pt) - let tall = (ui.ctx().content_rect().height() - 260.0).clamp(300.0, 760.0); - ui.horizontal_top(|ui| { - ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| { - ui.set_width(SLIDESHOW_COLUMN_W); - egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| { - super::slide_templates::section(app, ui, t); - ui.add_space(6.0); - playback_section(app, ui, t); - ui.add_space(6.0); - super::slide_layout::sections(app, ui, t); - ui.add_space(6.0); - super::slide_overlays::sections(app, ui, t); - ui.add_space(6.0); - super::slide_titles::sections(app, ui, t); - ui.add_space(6.0); - export_section(app, ui, t); - }); - }); - ui.add_space(12.0); - ui.allocate_ui_with_layout(egui::vec2(super::slide_layout::PREVIEW_W, tall), top_down, |ui| { - ui.set_width(super::slide_layout::PREVIEW_W); - super::slide_layout::preview(app, ui, t) - }); - }); +/// The Slideshow tab: the slide preview, then the settings, in one column the width of the page +/// (the page scrolls; a column beside the preview would be wider than the window). Without +/// `with_preview` the preview is left out: [`window`] has pinned it above the page. +fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, with_preview: bool) { + if with_preview { + super::slide_layout::preview(app, ui, t, f32::INFINITY); + ui.add_space(6.0); + } + super::slide_templates::section(app, ui, t); + ui.add_space(6.0); + playback_section(app, ui, t); + ui.add_space(6.0); + super::slide_layout::sections(app, ui, t); + ui.add_space(6.0); + super::slide_overlays::sections(app, ui, t); + ui.add_space(6.0); + super::slide_titles::sections(app, ui, t); + ui.add_space(6.0); + export_section(app, ui, t); } /// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Export")); let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { for (id, label, format) in [ ("slideshowExportPdf", "Export PDF…", "pdf"), ("slideshowExportJpeg", "Export JPEG…", "jpeg"), @@ -960,12 +1127,7 @@ fn theme_card<V: PartialEq + Copy>( }); register(ui.ctx(), format!("button:themeFavorite-{}", kind.id()), r.rect); if favorite || hovered { - crate::icons::paint( - ui.painter(), - r.rect.shrink(4.0), - if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star }, - if favorite { t.accent } else { t.text_dim }, - ); + crate::rating::paint_star(ui.painter(), r.rect.shrink(4.0), favorite, if favorite { t.accent } else { t.text_dim }); } if r.clicked() { if favorite { @@ -1291,7 +1453,7 @@ fn valid_library_name(s: &str) -> Result<&str, String> { } /// `base` inside `location`, or `base 2`, `base 3`… if that is taken. -fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { +pub(crate) fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { let first = location.join(base); if !first.exists() { return first; @@ -1299,27 +1461,55 @@ fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) } -/// `app.recentLibraries {clear?: bool}`: the recently opened libraries, newest first, as -/// `[{path, name, exists, open}]` (`exists: false`: moved or on a disconnected drive). `clear` -/// forgets them all. +/// `app.recentLibraries {remove?: path, clear?: bool}`: the recently opened libraries, newest +/// first, as `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last +/// background check found (`false`: moved, on a disconnected drive, not a folder or no longer a +/// library; `null`: not checked yet, or the check couldn't tell — a folder that can't be listed, an +/// unreadable catalog, a lookup or check that failed; `checking` says whether a check is running): the command never +/// looks at a folder itself, as one on an offline network share can take seconds to answer. +/// `lastOpened`: ISO 8601 time, `null` when not known. `remove` forgets that +/// one entry (an error if the list doesn't hold it) and `clear` forgets them all; neither touches +/// anything on disk (issue #1290). pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { + match p.get("remove") { + None | Some(Value::Null) => {} + Some(Value::String(path)) => { + if !forget_library(&mut app.ui.settings, path) { + return Err(format!("app.recentLibraries: {path:?} is not in the recent libraries")); + } + app.recent_checks.forget(path); + } + Some(_) => return Err("app.recentLibraries: remove must be a library path (a string)".into()), + } if p.get("clear").and_then(Value::as_bool) == Some(true) { - app.ui.settings.recent_libraries.clear(); + for path in std::mem::take(&mut app.ui.settings.recent_libraries) { + app.recent_checks.forget(&path); + } + app.ui.settings.recent_opened.clear(); } let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let list: Vec<Value> = app - .ui - .settings + let (settings, checks) = (&app.ui.settings, &app.recent_checks); + let list: Vec<Value> = settings .recent_libraries .iter() .map(|r| { let d = std::path::Path::new(r); - json!({"path": r, "name": library_name(d), "exists": d.is_dir(), "open": open.as_deref() == Some(d)}) + let exists = checks.exists(r); + json!({"path": r, "name": library_name(d), "exists": exists, "checking": checks.is_checking(r), "open": open.as_deref() == Some(d), "lastOpened": settings.recent_opened.get(r)}) }) .collect(); Ok(Value::Array(list)) } +/// Forget `path` in File ▸ Open Recent Library (and when it was opened); the library itself is +/// left where it is. False if the list didn't hold it. +pub fn forget_library(settings: &mut crate::state::AppSettings, path: &str) -> bool { + let before = settings.recent_libraries.len(); + settings.recent_libraries.retain(|r| r != path); + settings.recent_opened.remove(path); + settings.recent_libraries.len() != before +} + // ---------------------------------------------------------------------------- Display profile /// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile @@ -1423,3 +1613,48 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod path_tests { + use super::elide_path; + + /// Width = characters, so a limit of 20 is "20 characters". + fn fit(path: &str, max: usize) -> String { + elide_path(path, max as f32, |s| s.chars().count() as f32) + } + + #[test] + fn a_path_that_fits_is_left_alone() { + assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); + assert_eq!(fit("", 5), ""); + } + + #[test] + fn leading_folders_go_first_and_the_end_stays() { + assert_eq!(fit("/Users/me/Pictures/Library", 20), "…/Pictures/Library"); + assert_eq!(fit("/Users/me/Pictures/Library", 10), "…/Library"); + } + + /// Windows paths: `Path::display` gives backslashes there. + #[test] + fn a_path_with_backslashes_keeps_its_end_too() { + assert_eq!(fit(r"C:\Users\me\Pictures\Library", 20), r"…\Pictures\Library"); + assert_eq!(fit(r"C:\Users\me/Pictures\Library", 10), r"…\Library"); + } + + #[test] + fn a_last_folder_too_long_by_itself_loses_its_end() { + assert_eq!(fit("/Users/me/a-very-long-folder-name", 8), "a-very-…"); + assert_eq!(fit("no-separator-at-all", 8), "no-sepa…"); + assert_eq!(fit("/Users/Фотографии-из-отпуска", 8), "Фотогра…"); + } + + #[test] + fn a_separator_at_the_end_is_not_where_the_name_starts() { + assert_eq!(fit("/Users/me/Pictures/Library/", 11), "…/Library/"); + assert_eq!(fit("/Users/me/a-very-long-folder-name/", 8), "a-very-…"); + assert_eq!(fit(r"C:\Users\me\Library\", 11), r"…\Library\"); + assert_eq!(fit("///", 2), "…"); + assert_eq!(fit(r"\\server\share\folder", 15), r"…\share\folder"); + } +} diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs index 5a9ddb243..1acb2efa4 100644 --- a/crates/ui-egui/src/panels/slide_layout.rs +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -22,8 +22,10 @@ use crate::widgets::register; /// Seconds the layout must stay unchanged before the preview is rendered again. pub const DEBOUNCE: f64 = 0.15; -/// The preview's width in points. +/// The preview's greatest width in points (less where the room is narrower). pub const PREVIEW_W: f32 = 300.0; +/// The least tall the preview is drawn. +pub const PREVIEW_MIN_H: f32 = 80.0; #[derive(Clone, Copy)] enum Unit { @@ -162,27 +164,49 @@ fn screen_aspect(ctx: &egui::Context) -> f64 { if r.y > 0.0 { (r.x / r.y) as f64 } else { 1.6 } } -/// The small composed slide of the active photo, with the margin guides when they are on. -pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { +/// The small composed slide of the active photo, with the margin guides when they are on. It is +/// at most `max_h` tall (but for [`PREVIEW_MIN_H`], and without counting its heading). +pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f32) { heading(ui, t, "Preview"); // the template the pointer is over in the Template Browser, else the settings let template = super::slide_templates::hovered(ui.ctx()).and_then(|id| app.session.slide_template(&id).ok()); let look = template.as_ref().map_or_else(|| SlideLook::of(&app.session), |t| t.look.clone()); let s = look.layout.clone(); let overlays = look.overlays.clone(); + // The room for the preview is the settings' own shape, whichever template is pointed at: the + // page below it must not move as the pointer goes over the templates (it would leave the + // row it is over). A template of another shape is drawn as large as fits in that room. let ratio = s.aspect.ratio(screen_aspect(ui.ctx())) as f32; - let h = (PREVIEW_W / ratio).clamp(80.0, 320.0); - let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW_W, h), Sense::hover()); + let own = SlideLook::of(&app.session).layout.aspect.ratio(screen_aspect(ui.ctx())) as f32; + let w = ui.available_width().clamp(1.0, PREVIEW_W); + let room_h = (w / own).clamp(PREVIEW_MIN_H, 320.0).min(max_h.max(PREVIEW_MIN_H)); + let (room, _) = ui.allocate_exact_size(vec2(w, room_h), Sense::hover()); + let (fit_w, fit_h) = if ratio >= w / room_h { (w, w / ratio) } else { (room_h * ratio, room_h) }; + let rect = Rect::from_center_size(room.center(), vec2(fit_w, fit_h)); + let h = rect.height(); + let w = rect.width(); register(ui.ctx(), "slideshow:preview", rect); - let p = ui.painter_at(rect.expand(1.0)); + let p = ui.painter_at(room.expand(1.0)); p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); super::slide_titles::preview_choice(ui); if let Some(tpl) = &template { + // (said over the bottom of the preview and not below it, which would move the page) let name = if tpl.builtin { crate::i18n::tr(&tpl.name).to_string() } else { tpl.name.clone() }; - ui.label(egui::RichText::new(crate::i18n::tr_format!("Template preview: {name}", name = name)).size(11.0).color(t.accent)); + let mut said = crate::i18n::tr_format!("Template preview: {name}", name = name).to_string(); if !tpl.description.is_empty() { - hint(ui, t, &tpl.description); + said = format!("{said}\n{}", tpl.description); } + let galley = ui.painter().layout(said, t.font(11.0), Color32::WHITE, (room.width() - 8.0).max(1.0)); + let band = Rect::from_min_size( + pos2(room.left(), (room.bottom() - galley.size().y - 6.0).max(room.top())), + vec2(room.width(), galley.size().y + 6.0), + ); + let paint = ui + .ctx() + .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("settingsSlideshowTemplateSaid"))) + .with_clip_rect(room.intersect(ui.clip_rect())); + paint.rect_filled(band, 0.0, Color32::from_black_alpha(190)); + paint.galley(pos2(band.left() + 4.0, band.top() + 3.0), galley, Color32::WHITE); } if let Some(title) = super::slide_titles::preview_shows(ui.ctx()) { title_preview(app, ui, t, rect, title, &look); @@ -194,7 +218,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; }; let ppp = ui.ctx().pixels_per_point(); - let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); + let (pw, ph) = (((w * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); // the overlays and what they show of the photo (its rating, its metadata) change it too let shown = app.session.catalog.photo(id).filter(|_| overlays.any()).map_or(0, |p| { use std::hash::{Hash, Hasher}; diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 46ffbcb36..a1794fba4 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -141,10 +141,25 @@ fn paper_label(name: &str) -> String { "legal" => "Legal".into(), "tabloid" => "Tabloid".into(), "custom" => "Custom".into(), + // Japanese photo and postcard papers: the customary name and its metric size. + "l" => "L判 (89 × 127 mm)".into(), + "2l" => "2L判 (127 × 178 mm)".into(), + "kg" => "KG判 (102 × 152 mm)".into(), + "postcard" => "はがき (100 × 148 mm)".into(), + "double-postcard" => "往復はがき (148 × 200 mm)".into(), + "6cut" => "六切 (203 × 254 mm)".into(), + "4cut" => "四切 (254 × 305 mm)".into(), + "business-card" => "名刺 (55 × 91 mm)".into(), other => other.replace('x', " × ") + " in", } } +/// The paper the selector shows as current: `custom`, or the canonical name an alias stands for, +/// so a `print.set` with `はがき` still selects the `postcard` option. +fn current_paper(paper: &str) -> &str { + if paper.eq_ignore_ascii_case("custom") { "custom" } else { layout::paper_name(paper).unwrap_or(paper) } +} + /// The dialog's body: the settings column and the page preview side by side. pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { let ctx = ui.ctx().clone(); @@ -374,8 +389,8 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, heading(ui, t, "Page Setup"); row(ui, t, "Paper", |ui| { - let current = if l.page.paper.eq_ignore_ascii_case("custom") { "custom".to_string() } else { l.page.paper.to_ascii_lowercase() }; - let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(&current)).show_ui(ui, |ui| { + let current = current_paper(&l.page.paper); + let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(current)).show_ui(ui, |ui| { for name in layout::paper_names().chain(std::iter::once("custom")) { if ui.selectable_label(current == name, paper_label(name)).clicked() { patch(app, &ctx, json!({"layout": {"page": {"paper": name}}})); @@ -858,6 +873,8 @@ fn paint_cell( clip.add(egui::Shape::mesh(mesh)); } None => { + // the page is paper in every theme (its colour is the print's, not the theme's), so + // these stay fixed greys: a photo still to come, and an empty cell clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) }); } } @@ -961,5 +978,17 @@ mod tests { assert_eq!(trim(2.5), "2.5"); assert_eq!(paper_label("a4"), "A4"); assert_eq!(paper_label("4x6"), "4 × 6 in"); + // The Japanese photo and postcard papers show their customary name and metric size. + assert_eq!(paper_label("l"), "L判 (89 × 127 mm)"); + assert_eq!(paper_label("postcard"), "はがき (100 × 148 mm)"); + assert_eq!(paper_label("double-postcard"), "往復はがき (148 × 200 mm)"); + assert_eq!(paper_label("6cut"), "六切 (203 × 254 mm)"); + assert_eq!(paper_label("4cut"), "四切 (254 × 305 mm)"); + // The selector resolves an alias stored by print.set to the canonical option. + assert_eq!(current_paper("はがき"), "postcard"); + assert_eq!(current_paper("L判"), "l"); + assert_eq!(current_paper("Custom"), "custom"); + assert_eq!(paper_label(current_paper("はがき")), "はがき (100 × 148 mm)"); + assert_eq!(paper_label(current_paper("a4")), "A4"); } } diff --git a/crates/ui-egui/src/progress.rs b/crates/ui-egui/src/progress.rs new file mode 100644 index 000000000..9db1f8c25 --- /dev/null +++ b/crates/ui-egui/src/progress.rs @@ -0,0 +1,191 @@ +//! Work in progress, shown one way everywhere: a [`Progress`] bar (a known fraction, or a gentle +//! sweep when nothing is known yet) and a [`spinner`] for short waits beside text or in a busy +//! [`crate::button::Button`]. It replaces `egui::ProgressBar` and `ui.spinner()`. +//! +//! ```ignore +//! Progress::new(Some(done as f32 / total as f32)).label(text).widget("denoise:progress").show(ui); +//! Progress::new(None).label(tr("Starting…")).show(ui); +//! ui.horizontal(|ui| { spinner(ui, 14.0); ui.label(tr("Selecting…")); }); +//! ``` +//! +//! A bar announces itself to screen readers as a progress indicator with its percentage. Motion +//! asks for repaints only while it is on screen. There is no reduced-motion setting in the app, +//! so the motion is kept slow and small: one gentle sweep, one slow turning arc. + +use std::f32::consts::TAU; + +use egui::{Color32, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, pos2, vec2}; + +use crate::theme::Tokens; + +/// A bar in a model row: it leaves the row's Cancel button its place. +pub const ROW_WIDTH: f32 = 260.0; +/// The narrowest a bar that fills the available width is drawn. +const MIN_WIDTH: f32 = 96.0; +/// Height of a bar that carries text. +const HEIGHT_WITH_TEXT: f32 = 18.0; +/// Height of a bar without text. +const HEIGHT_PLAIN: f32 = 6.0; +/// Seconds an indeterminate bar takes to sweep across and back. +const SWEEP_SECONDS: f64 = 1.8; +/// Seconds a spinner takes for one turn. +const TURN_SECONDS: f64 = 1.2; +/// How often moving things redraw. +const FRAME: std::time::Duration = std::time::Duration::from_millis(33); + +/// `fraction` as a number from 0 to 1: NaN counts as nothing done, and out-of-range values stop at +/// the ends. +pub fn clamp_fraction(fraction: f32) -> f32 { + if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) } +} + +/// `done` of `total` as a fraction, or `None` while the total is not known (0): an unknown total +/// is a bar that sweeps, not one stuck at 0 %. +pub fn fraction_of(done: u64, total: u64) -> Option<f32> { + (total > 0).then(|| clamp_fraction(done as f32 / total as f32)) +} + +/// The whole percent a bar says for `fraction`: rounded down, so 100 comes only at exactly 1.0, but +/// with a little slack so a fraction that is exact in decimals (53 of 100, held as 0.52999997) +/// still says 53. A bar's text and its accessible value both come from here, so they agree. +pub fn percent(fraction: f32) -> u32 { + let f = clamp_fraction(fraction); + let whole = (f64::from(f) * 100.0 + 1e-3).floor() as u32; + if f < 1.0 { whole.min(99) } else { 100 } +} + +/// A progress bar, built and then shown with [`Progress::show`]. +#[derive(Clone, Debug)] +pub struct Progress { + fraction: Option<f32>, + label: Option<String>, + widget: Option<String>, + width: Option<f32>, + dimmed: bool, +} + +impl Progress { + /// `Some(fraction)` of the work done (clamped to 0..=1, NaN as 0), or `None` while it is not + /// known how much there is. + pub fn new(fraction: Option<f32>) -> Self { + Progress { fraction: fraction.map(clamp_fraction), label: None, widget: None, width: None, dimmed: false } + } + + /// The fraction drawn, once clamped (`None` for an indeterminate bar). + pub fn fraction(&self) -> Option<f32> { + self.fraction + } + + /// Text on the bar (a percentage, a count, a file's name), already translated. + pub fn label(mut self, text: impl Into<String>) -> Self { + self.label = Some(text.into()); + self + } + + /// Registered under this id for scripts, tests and `ui.clickWidget` (e.g. `progress:samDownload`). + pub fn widget(mut self, id: impl Into<String>) -> Self { + self.widget = Some(id.into()); + self + } + + /// Drawn in the quiet text colour instead of the accent (work that is being stopped). + pub fn dimmed(mut self, dimmed: bool) -> Self { + self.dimmed = dimmed; + self + } + + /// This wide; by default the bar fills the width the layout has left. + pub fn width(mut self, width: f32) -> Self { + self.width = Some(width); + self + } + + /// Show it where the layout puts it. + pub fn show(self, ui: &mut Ui) -> Response { + let t = Tokens::get(ui.ctx()); + let label = self.label.as_deref().filter(|s| !s.is_empty()); + let width = self.width.unwrap_or_else(|| ui.available_size_before_wrap().x.max(MIN_WIDTH)); + let height = if label.is_some() { HEIGHT_WITH_TEXT } else { HEIGHT_PLAIN }; + let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); + resp.widget_info(|| { + let mut info = match label { + Some(l) => egui::WidgetInfo::labeled(egui::Role::ProgressIndicator, ui.is_enabled(), l), + None => egui::WidgetInfo::new(egui::Role::ProgressIndicator), + }; + info.value = self.fraction.map(|f| f64::from(percent(f))); + info + }); + if let Some(w) = &self.widget { + crate::widgets::register(ui.ctx(), w.clone(), rect); + } + if !ui.is_rect_visible(rect) { + return resp; + } + let p = ui.painter_at(rect); + let radius = height / 2.0; + p.rect_filled(rect, radius, t.inset); + p.rect_stroke(rect, radius, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + let inner = rect.shrink(1.0); + let fill = match self.fraction { + Some(f) => Rect::from_min_size(inner.min, vec2(inner.width() * f, inner.height())), + None => { + // a short bar sweeping across and back, eased so it slows at the ends + let phase = (ui.input(|i| i.time) / SWEEP_SECONDS).fract() as f32; + let eased = 0.5 - 0.5 * (phase * TAU).cos(); + let len = inner.width() * 0.3; + let x = inner.left() + (inner.width() - len) * eased; + ui.ctx().request_repaint_after(FRAME); + Rect::from_min_size(pos2(x, inner.top()), vec2(len, inner.height())) + } + }; + if fill.width() > 0.0 { + p.with_clip_rect(inner).rect_filled(fill, radius - 1.0, if self.dimmed { t.text_dim } else { t.accent }); + } + if let Some(l) = label { + // the text reads on the track in the theme's colour, and in white where the fill is + let font = t.font(11.5); + let on_track = p.layout_no_wrap(l.to_string(), font.clone(), t.text); + let on_fill = p.layout_no_wrap(l.to_string(), font, Color32::WHITE); + let at = pos2(rect.center().x - on_track.size().x / 2.0, rect.center().y - on_track.size().y / 2.0); + let clipped = ui.painter_at(rect); + clipped.galley(at, on_track, t.text); + clipped.with_clip_rect(rect.intersect(fill)).galley(at, on_fill, Color32::WHITE); + } + resp + } +} + +/// The arc a spinner draws at `time` seconds: points on a circle of `radius` around `center`, +/// covering three quarters of the turn and rotating once per [`TURN_SECONDS`]. +fn arc_points(center: Pos2, radius: f32, time: f64) -> Vec<Pos2> { + let start = ((time / TURN_SECONDS).fract() as f32) * TAU; + let steps = 24; + (0..=steps) + .map(|i| { + let a = start + TAU * 0.75 * (i as f32 / steps as f32); + center + vec2(a.cos(), a.sin()) * radius + }) + .collect() +} + +/// Draw a spinner of `size` points centred in `rect`, in `color`, at `time` seconds. Callers that +/// draw by hand (a busy button) use this; it asks for no repaint itself. +pub fn paint_spinner(p: &egui::Painter, rect: Rect, color: Color32, time: f64) { + let size = rect.width().min(rect.height()); + let width = (size / 8.0).max(1.5); + let radius = ((size - width) / 2.0).max(0.5); + p.add(Shape::line(arc_points(rect.center(), radius, time), Stroke::new(width, color))); +} + +/// A small turning arc beside text that says what is being waited for. It is drawn as a vector +/// arc, not a glyph, and asks for repaints only while it is on screen. +pub fn spinner(ui: &mut Ui, size: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::new(egui::Role::ProgressIndicator)); + if ui.is_rect_visible(rect) { + paint_spinner(ui.painter(), rect, t.text_dim, ui.input(|i| i.time)); + ui.ctx().request_repaint_after(FRAME); + } + resp +} diff --git a/crates/ui-egui/src/rating.rs b/crates/ui-egui/src/rating.rs new file mode 100644 index 000000000..4faf48b9e --- /dev/null +++ b/crates/ui-egui/src/rating.rs @@ -0,0 +1,256 @@ +//! One star rating for the whole interface: the clickable row of five stars ([`Rating`]) and the +//! painter-only helpers for stars drawn onto photos and captions ([`paint_rating`], [`paint_star`]). +//! It replaces `widgets::stars` and the private star-painting loops of the grid badge, the film +//! badges, the compare caption and the filter bar. +//! +//! ```ignore +//! if let Some(r) = Rating::new("bottom", photo.rating).show(ui) { … } +//! paint_rating(ui.painter(), pos, 3, RatingSize::Small, color); +//! ``` +//! +//! Hovering a star previews the rating a click would set; clicking the current rating clears it. +//! The row is one Tab stop (the star of the current rating, or the first when unrated); inside it, ← and → +//! lower and raise the rating (0 to 5) and the focus follows. + +use egui::{Color32, Key, Modifiers, Painter, Pos2, Rect, Sense, Ui, pos2, vec2}; + +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; +use crate::widgets::{announce_selected, register}; + +/// Number of stars in a rating. +const STARS: u8 = 5; + +/// How big a rating is drawn. Each size matches one of the renderers it replaced. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RatingSize { + /// Film badges: 8 px stars every 9 px. + Small, + /// Grid badges and compare captions: 12 px stars every 13 px. + Medium, + /// The clickable row (bottom bar, Info panel, filter bar): 14 px stars in 19 px cells. + Large, +} + +impl RatingSize { + /// Side of one painted star. + pub fn icon(self) -> f32 { + match self { + Self::Small => 8.0, + Self::Medium => 12.0, + Self::Large => 14.0, + } + } + + /// Distance from one painted star to the next in [`paint_rating`]. + pub fn step(self) -> f32 { + match self { + Self::Small => 9.0, + Self::Medium => 13.0, + Self::Large => 17.0, + } + } + + /// Side of one clickable cell in a [`Rating`] row. + fn cell(self) -> f32 { + self.step() + 2.0 + } +} + +/// Paint one star (outline or filled) centred in `rect`. +pub fn paint_star(painter: &Painter, rect: Rect, filled: bool, color: Color32) { + paint(painter, rect, if filled { Icon::StarFilled } else { Icon::Star }, color); +} + +/// Paint `rating` filled stars in a row, the first one's left edge at `pos.x` and its vertical centre +/// at `pos.y`. Returns the width the row takes (0 for no stars), so the caller can lay out what follows. +pub fn paint_rating(painter: &Painter, pos: Pos2, rating: u8, size: RatingSize, color: Color32) -> f32 { + let n = rating.min(STARS); + let side = size.icon(); + for i in 0..n { + let left = pos.x + f32::from(i) * size.step(); + paint_star(painter, Rect::from_min_size(pos2(left, pos.y - side / 2.0), vec2(side, side)), true, color); + } + f32::from(n) * size.step() +} + +/// What the stars show once hovering is taken into account: the hovered star's rating previews over +/// the current one. +fn star_lit(i: u8, rating: u8, hover: Option<u8>) -> bool { + i < hover.unwrap_or(rating) +} + +/// The rating a click on star `n` (1 to 5) sets: clicking the current rating clears it. +fn clicked_rating(current: u8, n: u8) -> u8 { + if current == n { 0 } else { n } +} + +/// The rating the arrow keys move to from `current`, if any. +fn nudged_rating(current: u8, left: bool, right: bool) -> Option<u8> { + match (left, right) { + (true, false) if current > 0 => Some(current - 1), + (false, true) if current < STARS => Some(current + 1), + _ => None, + } +} + +/// A row of five stars. Build it, then [`Rating::show`]. +pub struct Rating { + id: String, + rating: u8, + ids_prefix: Option<String>, +} + +impl Rating { + /// A rating of `rating` (0 to 5) whose stars are reachable as `star:{id}:{n}`. + pub fn new(id: impl Into<String>, rating: u8) -> Self { + Self { id: id.into(), rating: rating.min(STARS), ids_prefix: None } + } + + /// Register the stars under `{prefix}{n}` instead of `star:{id}:{n}`, for rows that already have + /// published automation ids (the filter bar's `filter:star3`). + pub fn automation_ids(mut self, prefix: impl Into<String>) -> Self { + self.ids_prefix = Some(prefix.into()); + self + } + + fn widget_id(&self, n: u8) -> String { + match &self.ids_prefix { + Some(prefix) => format!("{prefix}{n}"), + None => format!("star:{}:{n}", self.id), + } + } + + fn star_id(&self, ui: &Ui, i: u8) -> egui::Id { + ui.scope_id().with(("rating-star", &self.id, i)) + } + + /// Draw the row. Returns the new rating when a click or an arrow key changed it. + pub fn show(self, ui: &mut Ui) -> Option<u8> { + let t = Tokens::get(ui.ctx()); + let cell = RatingSize::Large.cell(); + let mut out = None; + let mut hover_n = None; + let mut focused_at = None; + let mut cells = [Rect::NOTHING; STARS as usize]; + // One Tab stop for the row, like a radio group: the star of the current rating (the first when + // unrated), plus whichever star already has the focus so it keeps it + let stop = self.rating.max(1) - 1; + for (i, slot) in (0..STARS).zip(cells.iter_mut()) { + let id = self.star_id(ui, i); + let (r, _) = ui.allocate_exact_size(vec2(cell, cell), Sense::hover()); + let sense = if i == stop || ui.memory(|m| m.has_focus(id)) { Sense::click() } else { Sense::CLICK }; + let resp = ui.interact(r, id, sense); + announce_selected(&resp, format!("{} ★", i + 1), i < self.rating); + register(ui.ctx(), self.widget_id(i + 1), r); + if resp.hovered() { + hover_n = Some(i + 1); + } + if resp.clicked() { + out = Some(clicked_rating(self.rating, i + 1)); + } + if resp.has_focus() { + focused_at = Some(i); + // ← and → step the rating instead of moving the focus to the neighbouring widget + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, ..Default::default() }); + }); + ui.painter().rect_stroke(r.expand(1.0), 6.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); + } + *slot = r; + } + let focused = focused_at.is_some(); + if focused && out.is_none() { + let (left, right) = ui.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowLeft), i.consume_key(Modifiers::NONE, Key::ArrowRight))); + if left || right { + // egui picked the neighbour to focus from this same key before the lock above could apply (the + // first arrow after Tab): cancel that move + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + out = nudged_rating(self.rating, left, right); + if let Some(n) = out { + // keep the focus on the star that is now the row's Tab stop + let to = self.star_id(ui, n.max(1) - 1); + ui.memory_mut(|m| m.request_focus(to)); + } else if focused_at != Some(stop) { + // the rating changed from outside (undo, a shortcut, another photo): the focus follows the Tab stop + let to = self.star_id(ui, stop); + ui.memory_mut(|m| m.request_focus(to)); + } + } + // The hovered star also gets the icon-button hover square, so hovering the current rating (where + // the preview looks the same) still shows feedback. + let side = RatingSize::Large.icon(); + for (i, r) in (0..STARS).zip(cells) { + if hover_n == Some(i + 1) { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + let filled = star_lit(i, self.rating, hover_n); + paint_star(ui.painter(), Rect::from_center_size(r.center(), vec2(side, side)), filled, if filled { t.star } else { t.icon }); + } + out + } +} + +/// A frameless star toggle for favourites: filled when `favorite`, named `name` for screen readers and as +/// its tooltip, reachable as `widget` (a full automation id). A square one line tall. +pub fn favorite_button(ui: &mut Ui, widget: String, favorite: bool, name: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let side = ui.spacing().interact_size.y; + let (r, resp) = ui.allocate_exact_size(vec2(side, side), Sense::click()); + announce_selected(&resp, name, favorite); + // (rows scrolled out of view can't be clicked) + if ui.is_rect_visible(r) { + register(ui.ctx(), widget, r); + } + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + if resp.has_focus() { + ui.painter().rect_stroke(r.expand(1.0), 6.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); + } + let icon = RatingSize::Medium.icon(); + paint_star(ui.painter(), Rect::from_center_size(r.center(), vec2(icon, icon)), favorite, if favorite { t.star } else { t.icon }); + resp.on_hover_text(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_hovered_star_previews_its_rating() { + let lit = |rating, hover| (0..STARS).filter(|&i| star_lit(i, rating, hover)).count(); + assert_eq!(lit(2, None), 2, "no hover shows the rating"); + assert_eq!(lit(2, Some(4)), 4); + assert_eq!(lit(5, Some(1)), 1); + assert_eq!(lit(0, Some(3)), 3); + } + + #[test] + fn arrows_stay_between_zero_and_five() { + assert_eq!(nudged_rating(0, true, false), None); + assert_eq!(nudged_rating(5, false, true), None); + assert_eq!(nudged_rating(3, true, false), Some(2)); + assert_eq!(nudged_rating(3, false, true), Some(4)); + assert_eq!(nudged_rating(3, true, true), None); + } + + #[test] + fn rows_scrolled_out_of_view_are_not_registered() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 100.0))), ..Default::default() }, |ui| { + egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| { + for i in 0..30 { + favorite_button(ui, format!("effectStar:{i}"), false, "Favorite"); + } + }); + }); + out.textures_delta.clear(); + let ids: Vec<String> = crate::widgets::take_registry(&ctx).into_iter().map(|(id, _)| id).collect(); + assert!(ids.contains(&"effectStar:0".to_string()), "{ids:?}"); + assert!(!ids.contains(&"effectStar:29".to_string()), "{ids:?}"); + } +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 7759884b5..973c0d8b8 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -217,6 +217,8 @@ pub struct Renderer { pub last_main_ms: f64, /// Jobs finished since start (for inspect/perf). pub completed: u64, + /// Finished jobs thrown away because a newer request for their slot had replaced them. + pub discarded: u64, /// Keep a CPU copy of every texture so the UI can be rasterized headlessly (set when the app /// is driven by the control channel). pub keep_pixels: bool, @@ -297,6 +299,7 @@ impl Renderer { live_hdr_histogram: None, last_main_ms: 0.0, completed: 0, + discarded: 0, keep_pixels: false, stages: HashMap::new(), quick_tried: HashMap::new(), @@ -853,6 +856,7 @@ impl Renderer { && self.shown_ids.get(&slot).is_none_or(|shown| r.request_id > *shown) && r.rendered.is_ok(); if !newer_draft { + self.discarded += 1; continue; } session.accept(&r); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 378f65e66..828109c17 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -68,8 +68,10 @@ pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> { find_bindable(id).and_then(|b| binding(keymap, id, b.default)) } -/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]. -pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")]; +/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]: +/// the X pair by the Crop panel, A by whether it is open (Visualize Spots everywhere, the aspect +/// lock under the Crop panel). +pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect"), ("view.visualizeSpots", "crop.lockAspect")]; /// Commands other than `id` whose shortcut is the key `sc` (contextual partners excepted). pub fn conflicts(keymap: &Keymap, id: &str, sc: &str) -> Vec<&'static str> { @@ -442,9 +444,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| { compare::target_active(app, &mut params); + let was = app.session.active(); let ok = app.run(id, params).is_ok(); if ok && (advance || app.ui.auto_advance) { - compare::advance(app); + compare::advance(app, was); } }; for (id, params) in aliased { @@ -620,6 +623,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { continue; } + // A is both Visualize Spots and the crop aspect lock: each key only in its own context + if f == "view.visualizeSpots" && app.ui.right == crate::state::RightPanel::Crop { + continue; + } + if f == "crop.lockAspect" && app.ui.right != crate::state::RightPanel::Crop { + continue; + } // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() { let _ = app.run("spot.refreshSource", json!({})); @@ -630,12 +640,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let _ = app.run("view.maskOverlayColor", json!({})); continue; } - // while cropping: O cycles the guides, Shift+O their orientation, A locks the aspect + // while cropping: O cycles the guides, Shift+O their orientation if app.ui.right == crate::state::RightPanel::Crop { let crop_key = match f.as_str() { "view.maskOverlay" => Some(("view.cropOverlay", json!({}))), "view.cropOverlay" => Some(("view.cropOverlayOrientation", json!({}))), - "view.visualizeSpots" => Some(("crop.aspect", json!({"aspect": "toggle"}))), _ => None, }; if let Some((cmd, p)) = crop_key { @@ -923,6 +932,38 @@ mod tests { } } + #[test] + fn culling_keys_go_on_from_a_photo_that_leaves_the_filter() { + // #2312: the filter shows one-star photos; taking photo 2's star off makes photo 3 active, once, with or + // without Shift and Auto Advance, and → goes on from there instead of from the first photo + use lightcraft_catalog::PhotoId; + let one_star = || { + let mut h = library(); + h.app.session.execute("library.selectAll", &json!({})).unwrap(); + h.app.session.execute("photo.rate", &json!({"rating": 1})).unwrap(); + h.app.session.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"})).unwrap(); + h.app.session.execute("library.select", &json!({"ids": [2]})).unwrap(); + h + }; + for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::Detail] { + for (shift, auto) in [(false, false), (true, false), (false, true), (true, true)] { + let mut h = one_star(); + h.app.ui.view = view; + h.app.ui.auto_advance = auto; + key(&mut h, "0", shift); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().rating, 0); + assert_eq!(h.app.session.active(), Some(PhotoId(3)), "{view:?} Shift={shift} Auto Advance={auto}"); + key(&mut h, "right", false); + assert_eq!(h.app.session.active(), Some(PhotoId(4)), "{view:?} Shift={shift} Auto Advance={auto}"); + } + } + // the Photo menu with Auto Advance too + let mut h = one_star(); + h.app.ui.auto_advance = true; + crate::menubar::run_item(&mut h.app, "photo.rate", json!({"rating": 0})).unwrap(); + assert_eq!(h.app.session.active(), Some(PhotoId(3))); + } + #[test] fn color_label_actions_show_feedback_only_after_success() { use lightcraft_catalog::PhotoId; @@ -1127,7 +1168,8 @@ mod tests { } for (i, (k, a)) in ui.iter().enumerate() { for (k2, b) in &ui[i + 1..] { - assert!(k != k2, "{a} and {b} share a key"); + let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + assert!(k != k2 || contextual, "{a} and {b} share a key"); } } for (i, (k, a)) in engine.iter().enumerate() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index fd1e2f426..14db4f31b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -166,6 +166,18 @@ pub enum StartupView { Detail, } +/// What the desktop app opens at launch (Settings ▸ General ▸ At launch; issue #1290). `--library` +/// and `LIGHTCRAFT_LIBRARY` come first either way. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LaunchLibrary { + /// The library open when the app last quit (holding Alt/Option as the window appears asks once). + #[default] + Last, + /// A window lists the recent libraries to choose from. + Ask, +} + /// When grid cells show their rating / flag / edited badges. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -407,6 +419,15 @@ pub struct AppSettings { /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most /// [`RECENT_LIBRARIES`]). pub recent_libraries: Vec<String>, + /// When each recent library was last opened (path → the session clock's ISO 8601 time), for + /// the launch window. A separate key so files from earlier versions, and earlier versions + /// reading this one, keep their list (issue #1290). + #[serde(rename = "recentLibrariesOpened", deserialize_with = "lenient")] + pub recent_opened: std::collections::BTreeMap<String, String>, + /// Open the last library at launch, or ask (a value this version doesn't know reads as the + /// default). + #[serde(deserialize_with = "lenient")] + pub at_launch: LaunchLibrary, pub startup_view: StartupView, /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved /// `confirmDelete: false` by default (#1239). @@ -415,6 +436,11 @@ pub struct AppSettings { /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask /// again" turns it off). pub ask_before_move: bool, + /// A click on the dimmed backdrop around a dialog closes it, like Esc (off: a stray click + /// never throws away a dialog's choices). + pub close_dialogs_outside: bool, + /// Photos and folders dropped on the window import at once, without the import review. + pub import_dropped_directly: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is @@ -455,7 +481,7 @@ pub struct AppSettings { /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. #[serde(deserialize_with = "lenient")] pub keyword_chips: KeywordChips, - /// View ▸ Show Focus Point: draw the box the camera focused on (`view.focusPoint`). + /// View ▸ Focus Point: draw the box the camera focused on (`view.focusPoint`). pub focus_point: bool, /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point /// when the photo has one (`view.zoomToFocus`). @@ -463,6 +489,22 @@ pub struct AppSettings { /// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2. #[serde(deserialize_with = "lenient")] pub loupe_info: LoupeInfo, + /// File ▸ Import Lightroom Catalog…: ask whether the catalog merges into the open library or + /// gets a new one, or always merge (issue #2672; the window's "Don't ask again" picks that). + #[serde(deserialize_with = "lenient")] + pub lightroom_import: LightroomImport, +} + +/// Where File ▸ Import Lightroom Catalog… puts a catalog (Settings ▸ General ▸ Library). Saved as +/// `"ask"` / `"alwaysMerge"`; anything else in a hand-edited `ui.json` reads as [`Self::Ask`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightroomImport { + /// A window asks each time: merge into the open library, or create a new library for it. + #[default] + Ask, + /// Merge into the open library without asking (how imports worked before the window). + AlwaysMerge, } impl Default for AppSettings { @@ -470,9 +512,13 @@ impl Default for AppSettings { AppSettings { library_path: String::new(), recent_libraries: Vec::new(), + recent_opened: Default::default(), + at_launch: LaunchLibrary::Last, startup_view: StartupView::Last, confirm_delete: true, ask_before_move: true, + close_dialogs_outside: false, + import_dropped_directly: false, gpu: true, preview_limit: 0, external_editor: String::new(), @@ -493,6 +539,7 @@ impl Default for AppSettings { focus_point: false, zoom_to_focus: true, loupe_info: LoupeInfo::default(), + lightroom_import: LightroomImport::Ask, } } } @@ -712,6 +759,10 @@ pub struct UiState { /// a later save succeeds). #[serde(skip)] pub unsaved_seen: bool, + /// Help ▸ Debug ▸ Frame Rate (`view.frameRate`): the frame-rate display + /// (`crate::frame_rate`). A debugging aid, never saved; `LIGHTCRAFT_FPS=1` starts with it on. + #[serde(skip)] + pub frame_rate: bool, pub view: ViewMode, pub left_panel: bool, pub right: RightPanel, @@ -805,6 +856,10 @@ pub struct UiState { /// The last person page left: the People view offers a way back to it next to its title. #[serde(skip)] pub last_person: Option<String>, + /// Assisted Culling's burst settings last confirmed (gap in seconds, similarity), for this + /// session; `None` until the dialog is first confirmed (then 10 s, 0.93). + #[serde(skip)] + pub cull_burst: Option<(f32, f32)>, /// "More" faces the user hid with ×, for this session: (photo, region index). #[serde(skip)] pub dismissed_faces: std::collections::HashSet<(u64, usize)>, @@ -1176,10 +1231,13 @@ pub enum Dialog { /// Help ▸ What's New. WhatsNew, /// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best - /// of each burst. + /// of each burst; shots at most `burst_gap` seconds apart and at least `similarity` alike are + /// a burst. Cull { reject_below: f32, pick_best: bool, + burst_gap: f32, + similarity: f32, }, /// Help ▸ System Info: (label, value) rows. DenoiseModel { @@ -1260,6 +1318,29 @@ pub enum Dialog { folder: String, catalog: Option<String>, }, + /// File ▸ Import Lightroom Catalog…: where the catalog at `catalog` goes (issue #2672, see + /// [`crate::lightroom_destination`]). Merge into the open library `library` (`photos` in it), or + /// (`new_library`) create the library `name` in the folder `location`, switch to it and import + /// there. `dont_ask`: merge from now on without asking. `generation`: the library the window + /// was opened for (`Session::library_generation`): another one opened meanwhile isn't merged into. + LightroomDestination { + /// The `.lrcat` file. + catalog: String, + /// The open library's name (its folder's name). + library: String, + /// The photos in the open library. + photos: usize, + /// "Create a new library" is chosen (else "Merge into the open library"). + new_library: bool, + /// The new library's name (prefilled from the catalog's file name). + name: String, + /// The folder the new library goes in (prefilled with the open library's parent folder). + location: String, + /// "Don't ask again (always merge into the open library)". + dont_ask: bool, + /// `Session::library_generation` when the window opened. + generation: u64, + }, /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, /// the colour as `color_sat`), whether it carries Amount and the brush settings. SaveEffect { @@ -1366,6 +1447,7 @@ impl Default for UiState { language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, + frame_rate: std::env::var("LIGHTCRAFT_FPS").is_ok_and(|v| matches!(v.trim(), "1" | "on" | "true" | "yes")), luminance_map_restore: None, view: ViewMode::Detail, left_panel: false, @@ -1412,6 +1494,7 @@ impl Default for UiState { person_page: None, person_from: None, last_person: None, + cull_burst: None, dismissed_faces: Default::default(), unnamed_selected: Default::default(), unnamed_name: String::new(), diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 0473a38fa..e349f694b 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -169,7 +169,7 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { ui.add_space(6.0); let Some(c) = counts.as_ref() else { ui.horizontal(|ui| { - ui.spinner(); + crate::progress::spinner(ui, 14.0); match progress(app) { Some(ScanStage::Listing(folders, files)) => ui.label(listing_text(folders, files)), Some(ScanStage::Checking(done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))), diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 505ca4dfd..4ed2b3178 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -515,7 +515,7 @@ fn rows_added_later_grow_the_stack() { fn immediate_library_commands_and_auto_sync_show_completion() { let mut h = demo(); assert!(h.app.run("library.clearPreviews", json!({})).is_ok()); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("✓ Clear Preview Cache")); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("✓ Discard Preview Cache")); assert_eq!(h.app.run("develop.autoSync", json!({"on": true})).unwrap()["on"], true); assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Auto Sync: On")); diff --git a/crates/ui-egui/src/tests_canvas_pan.rs b/crates/ui-egui/src/tests_canvas_pan.rs new file mode 100644 index 000000000..3bb6ec51e --- /dev/null +++ b/crates/ui-egui/src/tests_canvas_pan.rs @@ -0,0 +1,62 @@ +//! A middle-button drag (the mouse wheel pressed) pans the zoomed photo in Detail whatever tool is +//! active: the healing brush doesn't paint meanwhile, and the hand cursor shows. + +use std::time::Duration; + +use egui::{Event, PointerButton, Pos2, vec2}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn spots(h: &Headless) -> usize { + let id = h.app.session.active().expect("active photo"); + h.app.session.develop_of(id).unwrap_or_default().spots.len() +} + +/// Press `button` at `from`, move to `to` over a few frames and release; the cursor seen mid-drag. +fn drag(h: &mut Headless, button: PointerButton, from: Pos2, to: Pos2) -> egui::CursorIcon { + let modifiers = egui::Modifiers::NONE; + h.app.synthetic.push(Event::PointerMoved(from)); + h.app.synthetic.push(Event::PointerButton { pos: from, button, pressed: true, modifiers }); + h.step(); + for i in 1..=6 { + h.app.synthetic.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + let cursor = h.last_cursor; + h.app.synthetic.push(Event::PointerButton { pos: to, button, pressed: false, modifiers }); + h.step(); + h.step(); + cursor +} + +#[test] +fn a_middle_drag_pans_with_the_healing_brush_and_adds_no_spot() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + for (method, params) in [ + ("ui.set", json!({"view": "detail"})), + ("engine.execute", json!({"command": "panel.remove"})), + ("engine.execute", json!({"command": "view.zoom100"})), + ] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + } + h.step(); + let at = h.app.image_rect.expect("the photo is shown").center(); + let pan = h.app.ui.pan; + let cursor = drag(&mut h, PointerButton::Middle, at, at + vec2(-150.0, -100.0)); + assert_eq!(cursor, egui::CursorIcon::Grabbing, "the hand shows while panning"); + assert_ne!(h.app.ui.pan, pan, "the middle drag pans"); + assert_eq!(spots(&h), 0, "the healing brush doesn't paint during a middle drag"); + // the left button still paints a spot, and doesn't pan + let pan = h.app.ui.pan; + let at = h.app.image_rect.expect("the photo is shown").center(); + drag(&mut h, PointerButton::Primary, at, at + vec2(40.0, 0.0)); + assert_eq!(spots(&h), 1, "a left drag paints a spot"); + assert_eq!(h.app.ui.pan, pan); +} diff --git a/crates/ui-egui/src/tests_capture_time.rs b/crates/ui-egui/src/tests_capture_time.rs new file mode 100644 index 000000000..b5903e053 --- /dev/null +++ b/crates/ui-egui/src/tests_capture_time.rs @@ -0,0 +1,174 @@ +//! Edit Capture Time on a photo whose own capture time doesn't fully read (a Lightroom catalog's +//! `captureTime` is copied verbatim): the preview says so instead of asking for a date the user +//! never typed, and OK leaves the photo as it was and says why. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(60); + +fn set_captured(h: &mut Headless, id: lightcraft_catalog::PhotoId, t: &str) { + h.app.session.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap(); +} + +fn painted(h: &Headless, part: &str) -> bool { + h.painted_text().iter().any(|t| t.contains(part)) +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +/// Select `ids` with `active` and open Edit Capture Time. +fn open(h: &mut Headless, ids: &[lightcraft_catalog::PhotoId], active: lightcraft_catalog::PhotoId) { + let ids: Vec<u64> = ids.iter().map(|id| id.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids, "active": active.0}}), T); + let r = h.request("ui.menu.invoke", json!({"id": "dialog.captureTime"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +#[test] +fn shift_preview_says_the_photos_capture_time_cant_be_read() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[0]; + set_captured(&mut h, id, "2026-01-01T10:00:00"); + open(&mut h, &[id], id); + match &mut h.app.ui.dialog { + Some(Dialog::CaptureTime { mode, hours, .. }) => { + *mode = "shift".into(); + *hours = 1; + } + other => panic!("Edit Capture Time is not open: {other:?}"), + } + h.settle(SETTLE); + assert!(painted(&h, "2026-01-01 10:00:00 → 2026-01-01 11:00:00"), "a readable time previews its shift: {:?}", h.painted_text()); + + let bad = "2026-01-01T10:00:00 and more"; + set_captured(&mut h, id, bad); + h.settle(SETTLE); + assert!(painted(&h, "can't be read"), "the preview says the photo's time can't be read: {:?}", h.painted_text()); + assert!(!painted(&h, "Enter a date"), "no date was typed, so the preview doesn't ask for one: {:?}", h.painted_text()); + + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.dialog.is_none(), "OK closes the dialog"); + let toast = toast(&h); + assert!(toast.contains("can't be read"), "OK says the photo was left as it was: {toast:?}"); + assert_eq!(h.app.session.catalog.photo(id).unwrap().captured.as_deref(), Some(bad), "the unreadable time is left as it was"); +} + +#[test] +fn set_mode_refuses_moving_others_by_an_unreadable_time_and_repairs_it_alone() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let ids = h.app.session.visible_cloned(); + let (bad, other) = (ids[0], ids[1]); + let broken = "2026-01-01T10:00:00 and more"; + set_captured(&mut h, bad, broken); + set_captured(&mut h, other, "2026-01-01T12:00:00"); + let open_set = |h: &mut Headless, select: &[lightcraft_catalog::PhotoId]| { + open(h, select, bad); + match &mut h.app.ui.dialog { + Some(Dialog::CaptureTime { mode, time, .. }) => { + *mode = "set".into(); + *time = "2026-01-01 11:00:00".into(); + } + other => panic!("Edit Capture Time is not open: {other:?}"), + } + h.settle(SETTLE); + }; + + // with another photo selected, OK can't move it by the unreadable time's change: it says so + // and stays open + open_set(&mut h, &[bad, other]); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let toast = toast(&h); + assert!(toast.contains("can't be read"), "OK says why nothing changed: {toast:?}"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::CaptureTime { .. })), "the dialog stays open after the refusal"); + let captured = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().captured.clone(); + assert_eq!(captured(&h, bad).as_deref(), Some(broken)); + assert_eq!(captured(&h, other).as_deref(), Some("2026-01-01T12:00:00"), "the other photo doesn't move"); + h.request("ui.dialog.cancel", json!({}), T); + + // alone, the preview shows the time it will get, and OK sets it + open_set(&mut h, &[bad]); + let preview = h.painted_text().into_iter().find(|t| t.contains(" → ")).unwrap_or_default(); + assert!(preview.ends_with(" → 2026-01-01 11:00:00"), "the preview shows the time it will get, nothing after: {preview:?}"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.dialog.is_none(), "OK closes the dialog"); + assert_eq!(captured(&h, bad).as_deref(), Some("2026-01-01T11:00:00"), "alone, it gets the time typed"); +} + +/// Errors from the command say why and keep the dialog (and what was typed) open, from its OK +/// button and from the control channel alike; they used to close it without a word. +#[test] +fn errors_are_shown_and_the_dialog_stays_open() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[0]; + set_captured(&mut h, id, "9999-12-31T12:00:00"); + open(&mut h, &[id], id); + match &mut h.app.ui.dialog { + Some(Dialog::CaptureTime { time, .. }) => *time = "yesterday".into(), + other => panic!("Edit Capture Time is not open: {other:?}"), + } + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); + h.settle(SETTLE); + assert!(toast(&h).contains("not a date"), "OK says the typed time isn't a date: {:?}", toast(&h)); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::CaptureTime { time, .. }) if time == "yesterday"), "the dialog stays open with what was typed"); + + // a shift past year 9999, confirmed through the control channel + match &mut h.app.ui.dialog { + Some(Dialog::CaptureTime { mode, days, .. }) => { + *mode = "shift".into(); + *days = 1; + } + other => panic!("Edit Capture Time is not open: {other:?}"), + } + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_ne!(r["ok"], true, "the shift is refused: {r}"); + assert!(r.to_string().contains("9999"), "{r}"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::CaptureTime { .. })), "ui.dialog.confirm keeps it open too, as the button does"); + assert_eq!(h.app.session.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00")); +} + +/// A capture time that doesn't fully read isn't offered as the time to set: `2026-01-01T10:0` +/// would read as midnight. The field starts empty and the preview asks for a date. +#[test] +fn set_mode_does_not_start_from_an_unreadable_time() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[0]; + set_captured(&mut h, id, "2026-01-01T10:00:00"); + open(&mut h, &[id], id); + assert!( + matches!(&h.app.ui.dialog, Some(Dialog::CaptureTime { time, .. }) if time == "2026-01-01 10:00:00"), + "a readable time is offered: {:?}", + h.app.ui.dialog + ); + h.request("ui.dialog.cancel", json!({}), T); + + set_captured(&mut h, id, "2026-01-01T10:0"); + open(&mut h, &[id], id); + assert!( + matches!(&h.app.ui.dialog, Some(Dialog::CaptureTime { time, .. }) if time.is_empty()), + "an unreadable time isn't offered: {:?}", + h.app.ui.dialog + ); + assert!(painted(&h, "Enter a date"), "alone, the photo will get the time typed, so the preview asks for one: {:?}", h.painted_text()); +} diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index 4d3b45702..a658ee9a8 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -6,6 +6,7 @@ use serde_json::json; use crate::headless::Headless; use crate::{LightcraftApp, Services}; +use lightcraft_pipeline::ParametricCurve; const T: Duration = Duration::from_secs(20); @@ -221,3 +222,370 @@ fn curve_points_from_an_agent_are_stored_in_order() { let c = pts(&develop(&h).curve.green); assert!(c[1].0 < c[2].0, "dragged up to its neighbour, not past it: {c:?}"); } + +/// The settings the parametric graph is drawn from: two neighbouring regions pushed opposite ways, +/// where the raw sum of their bumps folds back on itself before the renderer's running maximum +/// straightens it out. +fn folded_regions() -> lightcraft_develop::ToneCurve { + lightcraft_develop::ToneCurve { shadows: 90.0, darks: -80.0, lights: -70.0, highlights: 60.0, ..Default::default() } +} + +/// The bare sum of the four region bumps is not monotone, so the drawn curve needs the renderer's +/// running maximum: this is the shape that made the graph disagree with the sliders. +#[test] +fn the_parametric_model_is_monotone_where_the_bare_sum_of_bumps_is_not() { + let c = folded_regions(); + let raw = |x: f64| { + let s = [0.0, c.split_shadows / 100.0, c.split_mid / 100.0, c.split_highlights / 100.0, 1.0]; + let amounts = [c.shadows, c.darks, c.lights, c.highlights]; + let mut d = 0.0; + for i in 0..4 { + let (a, b) = (s[i], s[i + 1]); + let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05); + let t = ((x - ctr) / half).clamp(-1.0, 1.0); + d += amounts[i] / 100.0 * 0.22 * (0.5 + 0.5 * (t * std::f64::consts::PI).cos()); + } + x + d * 4.0 * x * (1.0 - x) + }; + assert!((0..=64).any(|i| raw(i as f64 / 64.0) > raw((i + 1) as f64 / 64.0)), "the shape folds back, or it proves nothing"); + let model = ParametricCurve::new(&c); + let mut prev = f64::MIN; + for i in 0..=256 { + let y = model.at(i as f64 / 256.0); + assert!(y >= prev - 1e-12, "the model never folds back, at {i}: {y} < {prev}"); + prev = y; + } +} + +/// What the graph *paints* is the model the renderer computes from the sliders. They used to be two +/// copies of the formula and the panel's copy was missing the renderer's monotone pass, so the drawn +/// line could fold back while the renderer straightened it: the graph and the sliders below it +/// disagreed. This reads the painted line itself, not the function behind it. +#[test] +fn the_parametric_graph_paints_the_model_the_renderer_uses() { + let mut h = curve_open("parametric"); + let c = folded_regions(); + for (control, value) in [("curve.shadows", c.shadows), ("curve.darks", c.darks), ("curve.lights", c.lights), ("curve.highlights", c.highlights)] { + exec(&mut h, "develop.set", json!({"control": control, "value": value})); + } + h.step(); + h.step(); + let settings = develop(&h).curve; + assert_eq!(settings, c, "the sliders hold the folded shape"); + let graph = curve_graph_rect(&mut h); + let painted = painted_param_curve(&h, graph); + let model = ParametricCurve::new(&c); + let mut checked = 0; + for i in 0..=24 { + let x = i as f64 / 24.0; + let want = model.at(x); + let got = painted(x); + assert!((got - want).abs() < 0.02, "the painted curve is the renderer's at {x}: painted {got}, model {want}"); + checked += 1; + } + assert_eq!(checked, 25); + // and the shape really does fold back before the monotone pass, so this proves something + assert!(model.at(0.6) > model.at(0.3), "the region bumps still bend the curve"); +} + +/// The graph rect as `[left, top, width, height]` plus a reader for the painted curve's height at a +/// graph fraction (0 = the bottom of the graph, 1 = the top). +fn curve_graph_rect(h: &mut Headless) -> [f32; 4] { + let r = h.request("ui.widgets", json!({"filter": "curve"}), T); + let w = r["result"].as_array().unwrap().iter().find(|w| w["id"] == "curve").cloned().unwrap_or_else(|| panic!("curve widget on screen: {r}")); + let v = w["rect"].as_array().unwrap(); + std::array::from_fn(|i| v[i].as_f64().unwrap() as f32) +} + +/// A reader for the height of the painted parametric curve at a graph fraction. The curve is the +/// wide grey path the graph draws; the histogram is a mesh and the grid lines are single segments. +fn painted_param_curve(h: &Headless, r: [f32; 4]) -> impl Fn(f64) -> f64 { + let points = h + .view + .painted_shapes() + .into_iter() + .find_map(|s| match s { + egui::Shape::Path(p) if p.points.len() > 32 && (p.stroke.width - 2.0).abs() < 0.1 => Some(p.points), + _ => None, + }) + .unwrap_or_else(|| panic!("the parametric curve line is painted")); + move |x: f64| { + let want_x = r[0] + x as f32 * r[2]; + let q = points + .iter() + .min_by(|a, b| (a.x - want_x).abs().total_cmp(&(b.x - want_x).abs())) + .copied() + .unwrap_or_else(|| panic!("the curve has points")); + ((r[1] + r[3] - q.y) / r[3]).clamp(0.0, 1.0) as f64 + } +} + +/// The tone the parametric graph paints at `x`, read by value from the painted line. Stepped first +/// so the panel has drawn the state the command left behind. +fn painted_tone(h: &mut Headless, x: f64) -> f64 { + h.step(); + h.step(); + let r = curve_graph_rect(h); + painted_param_curve(h, r)(x) +} + +/// Where the midtones sit on a linear curve: a point well inside the Lights region, so lifting +/// Lights bends the line there. +const MID: f64 = 0.625; + +/// Reset restores the four sliders *and* puts the graph back to the straight diagonal, as one state. +#[test] +fn reset_clears_the_sliders_and_the_graph_together() { + let mut h = curve_open("parametric"); + let straight = painted_tone(&mut h, MID); + assert!((straight - MID).abs() < 0.02, "a fresh parametric curve is the diagonal: {straight}"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); + let bent = painted_tone(&mut h, MID); + assert!(bent > MID + 0.02, "the graph follows the slider up: {bent}"); + let undo_before = h.app.session.undo.len(); + click_widget(&mut h, "button:curveReset", 1); + let c = develop(&h).curve; + assert_eq!(c, lightcraft_develop::ToneCurve::default(), "Reset puts every slider and split back: {c:?}"); + let flat = painted_tone(&mut h, MID); + assert!((flat - MID).abs() < 0.02, "and the graph is the diagonal again: {flat}"); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "Reset is one undo step"); +} + +/// Undo after a Reset brings back both the slider and the graph: the state the graph draws is the +/// state Undo restores, so the two can never be left showing different things. +#[test] +fn undo_restores_the_slider_and_the_graph_together() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); + let bent = painted_tone(&mut h, MID); + assert!(bent > MID + 0.02, "the graph is bent: {bent}"); + click_widget(&mut h, "button:curveReset", 1); + assert_eq!(develop(&h).curve, lightcraft_develop::ToneCurve::default(), "reset first"); + exec(&mut h, "edit.undo", json!({})); + let c = develop(&h).curve; + assert_eq!(c.lights, 60.0, "Undo brings the slider back: {c:?}"); + let again = painted_tone(&mut h, MID); + assert!(again > MID + 0.02, "and the graph is bent with it, not left flat: {again}"); + // redo puts the diagonal back, and the graph follows that too + exec(&mut h, "edit.redo", json!({})); + assert_eq!(develop(&h).curve, lightcraft_develop::ToneCurve::default(), "redo restores the reset"); + let flat = painted_tone(&mut h, MID); + assert!((flat - MID).abs() < 0.02, "the graph is flat with the sliders: {flat}"); +} + +/// One graph drag that bends several regions at once is one undo step: Ctrl+Z takes back the whole +/// gesture, not the last slider it happened to touch. +#[test] +fn one_graph_drag_that_moves_several_regions_is_one_undo_step() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40})); + exec(&mut h, "develop.set", json!({"control": "curve.darks", "value": -40})); + let before = develop(&h).curve; + let undo_before = h.app.session.undo.len(); + // drag the Lights slider's own track, the way a graph handle writes it: begin, live updates, end + exec(&mut h, "develop.beginInteraction", json!({"label": "Parametric Curve"})); + for v in [45, 55, 70, 85] { + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": v})); + } + exec(&mut h, "develop.endInteraction", json!({})); + let after = develop(&h).curve; + assert_eq!(after.lights, 85.0, "the drag ended at its last value: {after:?}"); + assert_ne!(after, before, "the drag changed the state"); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "the whole drag is one step, not one per frame"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h).curve, before, "undo takes back the whole gesture at once"); +} + +/// The screen point where a drag should grab the curve: graph fraction `fx`, on the line itself. +fn on_curve(r: [f32; 4], c: &lightcraft_develop::ToneCurve, fx: f32) -> [f32; 2] { + let y = ParametricCurve::new(c).at(fx as f64); + [r[0] + fx * r[2], r[1] + (1.0 - y as f32) * r[3]] +} + +/// Dragging the curve itself bends the region under the pointer and moves that region's slider with +/// it, live. The graph draws no handles, so the only thing to grab is the line. +#[test] +fn dragging_the_curve_moves_the_region_slider() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); + h.step(); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + let undo_before = h.app.session.undo.len(); + // grab the line inside the Lights window (x = 0.625) and pull it down + let [x, y] = on_curve(r, &c, 0.625); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y + 0.08 * r[3], "steps": 8}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + assert!(after.lights < 60.0, "the Lights slider follows the line down: {}", after.lights); + assert!(after.lights > 0.0, "and lands on a value, not at the bottom: {}", after.lights); + assert_eq!((after.shadows, after.darks, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}"); + assert_eq!(after.split_shadows, c.split_shadows, "and the splits are untouched"); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step for the whole drag"); + // the graph really is redrawn below where the line was + let graph = curve_graph_rect(&mut h); + let painted = painted_param_curve(&h, graph)(0.625); + let before_level = ParametricCurve::new(&c).at(0.625); + assert!(painted < before_level - 0.02, "the painted line followed the drag down: {painted} vs before {before_level}"); + // and Undo takes the whole gesture back to where it started + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h).curve, c, "undo restores the value the drag started from"); +} + +/// A horizontal drag on the curve (zero vertical motion) changes no region amount, even when +/// neighbouring regions contribute heavily to the curve at the grabbed position (AlexChambers report: +/// Darks=60, Lights=0 grabbed at x=0.51 used to jump Lights to 45 with zero vertical motion). +#[test] +fn a_horizontal_drag_with_zero_vertical_motion_changes_nothing() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.darks", "value": 60})); + h.step(); + let before = develop(&h).curve; + assert_eq!(before.darks, 60.0); + assert_eq!(before.lights, 0.0); + let r = curve_graph_rect(&mut h); + let [x, y] = on_curve(r, &before, 0.51); + // Drag purely horizontally from x=0.51: dx = 20 px, dy = 0 + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 20.0, "toY": y, "steps": 6}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + assert_eq!(after.lights, 0.0, "horizontal motion preserves Lights=0: {}", after.lights); + assert_eq!(after.darks, 60.0, "Darks is preserved: {}", after.darks); + assert_eq!(after, before, "horizontal drag changes nothing: {after:?}"); +} + +/// Dragging up bends the region the other way, so the gesture is not just "downwards works". +#[test] +fn dragging_the_curve_up_raises_the_region_slider() { + let mut h = curve_open("parametric"); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + // x = 0.375 is inside the Darks window + let [x, y] = on_curve(r, &c, 0.375); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y - 0.08 * r[3], "steps": 8}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + assert!(after.darks > 0.0, "the Darks slider follows the line up: {}", after.darks); + assert_eq!((after.shadows, after.lights, after.highlights), (0.0, 0.0, 0.0), "the other regions stay put: {after:?}"); + assert!(ParametricCurve::new(&after).at(0.375) > ParametricCurve::new(&c).at(0.375), "the curve is drawn bent up at the Darks region"); +} + +/// Which region a drag moves is decided by where on the line it is grabbed: the same drag gesture +/// four times over grabs four different sliders. +#[test] +fn the_region_dragged_is_the_one_under_the_pointer() { + for (fx, control, region) in + [(0.125_f32, "curve.shadows", 0usize), (0.375, "curve.darks", 1), (0.625, "curve.lights", 2), (0.875, "curve.highlights", 3)] + { + let mut h = curve_open("parametric"); + let r = curve_graph_rect(&mut h); + let c = develop(&h).curve; + assert_eq!(ParametricCurve::new(&c).region_at(fx as f64), region, "x = {fx} belongs to {control}"); + let [x, y] = on_curve(r, &c, fx); + let d = h.request("ui.drag", json!({"x": x, "y": y, "toX": x, "toY": y - 0.06 * r[3], "steps": 6}), T); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + let after = develop(&h).curve; + let moved = after.shadows + after.darks + after.lights + after.highlights; + let want = match region { + 0 => after.shadows, + 1 => after.darks, + 2 => after.lights, + _ => after.highlights, + }; + assert!(want > 0.0, "grabbing at {fx} raises {control}: {after:?}"); + assert!((moved - want).abs() < 1e-9, "and nothing else moved: {after:?}"); + } +} + +/// A drag that starts away from the line is not a graph gesture at all: nothing moves and no undo +/// step is made (the graph is not a "click anywhere to bend" surface). +#[test] +fn a_parametric_drag_off_the_line_changes_nothing() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 40})); + h.step(); + let before = develop(&h).curve; + let undo = h.app.session.undo.len(); + let r = curve_graph_rect(&mut h); + // a corner of the graph, far above the line + let d = h.request( + "ui.drag", + json!({"x": r[0] + 0.06 * r[2], "y": r[1] + 0.06 * r[3], "toX": r[0] + 0.2 * r[2], "toY": r[1] + 0.3 * r[3], "steps": 6}), + T, + ); + assert_eq!(d["ok"], true, "{d}"); + h.step(); + assert_eq!(develop(&h).curve, before, "no control moved"); + assert_eq!(h.app.session.undo.len(), undo, "and no undo step was made"); +} + +/// Hovering the line names the region a press there would move (and offers the grab cursor), so the +/// graph says which slider it is about to change before the drag starts. +#[test] +fn hovering_the_line_names_the_region_it_would_move() { + let mut h = curve_open("parametric"); + exec(&mut h, "develop.set", json!({"control": "curve.lights", "value": 60})); + h.step(); + let r = curve_graph_rect(&mut h); + let [x, y] = on_curve(r, &develop(&h).curve, 0.625); + let q = h.request("ui.move", json!({"x": x, "y": y}), T); + assert_eq!(q["ok"], true, "{q}"); + h.step(); + assert_eq!(h.last_cursor, egui::CursorIcon::Grab, "the line offers a grab: {:?}", h.last_cursor); + // the slider rows also paint "Lights", so compare with a frame away from the line + let lights = |h: &Headless| h.painted_text().iter().filter(|t| t.contains("Lights")).count(); + let hovered = lights(&h); + // well above the line there is no grab and no name + let q = h.request("ui.move", json!({"x": x, "y": r[1] + 0.06 * r[3]}), T); + assert_eq!(q["ok"], true, "{q}"); + h.step(); + assert_eq!(h.last_cursor, egui::CursorIcon::Default, "away from the line there is nothing to grab: {:?}", h.last_cursor); + assert!(hovered > lights(&h), "hovering names the slider it would move: {hovered} vs {} away", lights(&h)); +} + +/// A point's level on the region's curve and its slider amount are inverses, so a drag puts the line +/// exactly where the hand is, at whatever point on the graph it was grabbed. A level the region +/// cannot reach saturates rather than jumping. +#[test] +fn a_curve_level_and_its_amount_are_inverses() { + let c = lightcraft_develop::ToneCurve { shadows: 20.0, lights: 40.0, ..Default::default() }; + let model = ParametricCurve::new(&c); + for region in 0..4 { + let centre = model.region_x(region); + let (_, upper) = model.region_window(region); + // the region's window centre, and a point off-centre inside the same window + for x in [centre, centre + (upper - centre) * 0.5] { + for amount in [-100.0, -40.0, 0.0, 30.0, 100.0] { + let level = model.handle_level_at(region, amount, x); + let back = model.amount_at_level(region, level, x); + assert!((back - amount).abs() <= 1.0, "region {region} at {x}: {amount} → {level} → {back}"); + } + } + } + let centre = model.region_x(3); + assert_eq!(model.amount_at_level(3, 1.0, centre), 100.0, "a level above the region's reach saturates high"); + assert_eq!(model.amount_at_level(3, 0.0, centre), -100.0, "and one below saturates low"); +} + +/// The four regions tile the graph: every position belongs to exactly one of them, in order, so a +/// drag anywhere on the line moves exactly one slider and the one the pointer is over. +#[test] +fn the_four_regions_tile_the_graph() { + let model = ParametricCurve::new(&lightcraft_develop::ToneCurve::default()); + assert_eq!(model.region_at(0.0), 0, "black is the darkest region"); + assert_eq!(model.region_at(1.0), 3, "white is the lightest"); + assert_eq!(model.region_at(0.2), 0, "below the first split"); + assert_eq!(model.region_at(0.3), 1, "between the first two splits"); + assert_eq!(model.region_at(0.6), 2); + assert_eq!(model.region_at(0.8), 3); + let mut prev = model.region_at(0.0); + for i in 0..=100 { + let r = model.region_at(i as f64 / 100.0); + assert!(r >= prev, "the regions never go backwards: {r} after {prev}"); + prev = r; + } +} diff --git a/crates/ui-egui/src/tests_dialog_settings.rs b/crates/ui-egui/src/tests_dialog_settings.rs new file mode 100644 index 000000000..c62bc2f59 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_settings.rs @@ -0,0 +1,374 @@ +//! The Settings window is one size in one place whatever tab is open, and lies inside the app +//! window: a row with more choices than fit wraps, a label longer than its column wraps, a long +//! folder path is cut short (its tooltip has all of it), and the tabs stay put while a page scrolls. +//! +//! Before, a wide row widened the whole window: General was 970 points wide and the other tabs +//! about 600, the window moved up and down between tabs, and in a 900 point wide app window the +//! row labels and the Close button of General were cut off at the left. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::settings::TABS; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// The app window sizes tried: roomy, the desktop minimum, and smaller than that. +const SIZES: [[f32; 2]; 3] = [[1600.0, 1000.0], [900.0, 560.0], [640.0, 480.0]]; + +fn headless(session: lightcraft_engine::Session, size: [f32; 2]) -> Headless { + let mut h = Headless::new(LightcraftApp::new(session, Services::default()), size, 1.0); + // let the first renders finish (a render still running at exit can crash in GPU teardown) + h.settle(T); + h +} + +fn demo(size: [f32; 2]) -> Headless { + headless(lightcraft_engine::Session::with_demo(), size) +} + +/// Given Settings open on `tab`, drawn until its layout has settled. +fn show(h: &mut Headless, tab: &str) { + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": tab}}), T); + assert_eq!(r["ok"], true, "app.settings {tab}: {r}"); + h.settle(T); + for _ in 0..4 { + h.step(); + } +} + +/// A folder under the system's temporary one, removed again when the test ends, passed or not. +struct TempFolder(std::path::PathBuf); + +impl TempFolder { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("lc-ui-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + Self(dir) + } +} + +impl Drop for TempFolder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// The controls the Settings window registers on the tab that is open (and its tab buttons). +fn controls(h: &Headless) -> Vec<(String, egui::Rect)> { + let named = |id: &str| id.split_once(':').is_some_and(|(_, name)| name.starts_with("settings") || name.starts_with("theme")); + h.app.widgets.iter().filter(|(id, _)| named(id)).cloned().collect() +} + +fn same(a: egui::Rect, b: egui::Rect) -> bool { + (a.min - b.min).abs().max_elem() < 0.5 && (a.max - b.max).abs().max_elem() < 0.5 +} + +#[test] +fn the_window_keeps_its_size_and_place_from_tab_to_tab() { + for size in SIZES { + let mut h = demo(size); + // Given the first tab, when every other tab is opened in turn + show(&mut h, "general"); + let first = rect(&h, "dialog:window"); + for (tab, _) in TABS { + show(&mut h, tab); + // Then the window has not moved or changed size + let now = rect(&h, "dialog:window"); + assert!(same(first, now), "{size:?}: Settings is {now:?} on {tab}, and was {first:?} on general"); + } + // And going back to the first tab finds it as it was + show(&mut h, "general"); + assert!(same(first, rect(&h, "dialog:window")), "{size:?}: general again"); + } +} + +#[test] +fn every_tab_keeps_its_controls_inside_the_window() { + for size in SIZES { + let mut h = demo(size); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + for (tab, _) in TABS { + // When a tab is opened + show(&mut h, tab); + // Then the Settings window lies inside the app window + let window = rect(&h, "dialog:window"); + assert!(screen.contains_rect(window), "{size:?} {tab}: Settings {window:?} runs off the {screen:?} window"); + // And no control reaches past its left or right edge + // (the page is everything the tab drew, whether or not it has a name of its own; a + // control past the room for it would be clipped there, inside the window's margin) + let (page, room) = (rect(&h, "settings:page"), rect(&h, "settings:pageRoom")); + assert!( + page.left() >= room.left() - 0.5 && page.right() <= room.right() + 0.5, + "{size:?} {tab}: the page {page:?} is wider than the room for it {room:?}" + ); + assert!(window.contains_rect(room), "{size:?} {tab}: the page's room {room:?} is inside Settings {window:?}"); + for (id, r) in controls(&h) { + let within = if id.contains("settingsTab-") { window } else { room }; + assert!(r.left() >= within.left() - 0.5 && r.right() <= within.right() + 0.5, "{size:?} {tab}: {id} {r:?} is outside {within:?}"); + } + } + } +} + +#[test] +fn a_row_of_many_choices_wraps_under_itself() { + // Given an app window too narrow for the languages in one line + let mut h = demo([900.0, 560.0]); + show(&mut h, "general"); + let languages: Vec<egui::Rect> = (0..crate::i18n::Locale::ALL.len()).map(|i| rect(&h, &format!("button:settingsLanguage-{i}"))).collect(); + // Then they take more than one line + let first = languages[0]; + assert!(languages.iter().any(|r| r.top() > first.bottom()), "the languages wrap: {languages:?}"); + // And each further line starts under the first choice, not under the row's label + for r in languages.iter().filter(|r| r.top() > first.bottom()) { + assert!(r.left() >= first.left() - 0.5, "{r:?} starts left of the first language {first:?}"); + } + assert!( + languages.iter().filter(|r| r.top() > first.bottom()).any(|r| (r.left() - first.left()).abs() < 0.5), + "a wrapped line starts at the first choice" + ); +} + +#[test] +fn a_label_longer_than_its_column_leaves_the_controls_in_line() { + // Given Performance, whose "Forget unchanged photos" label is wider than the label column + let mut h = demo([1600.0, 1000.0]); + show(&mut h, "performance"); + // Then its choices start where the choices of a short label do + let (short, long) = (rect(&h, "button:settingsPreview-0"), rect(&h, "button:settingsForgetLocal-0")); + assert!((short.left() - long.left()).abs() < 0.5, "Preview size starts at {} and Forget unchanged photos at {}", short.left(), long.left()); +} + +#[test] +fn a_long_library_folder_is_cut_short_and_its_tooltip_has_all_of_it() { + // Given a library in a folder with a very long path + let base = TempFolder::new("settings-window"); + let dir = + base.0.join("photographs-from-2019-to-2026-kept-on-the-second-external-drive-that-nobody-wants-to-type-twice").join("the-master-library"); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&dir, true).unwrap(); + let path = dir.display().to_string(); + + let mut h = headless(session, [1600.0, 1000.0]); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + // When Settings opens on General, which shows where the library is + show(&mut h, "general"); + let general = rect(&h, "dialog:window"); + show(&mut h, "display"); + // Then the window is as wide as on a tab without a path + assert!(same(general, rect(&h, "dialog:window")), "General is {general:?} with a long path, Display is {:?}", rect(&h, "dialog:window")); + show(&mut h, "general"); + assert!(screen.contains_rect(general), "Settings {general:?} runs off the {screen:?} window"); + // And the path is cut short, inside the window + let location = rect(&h, "text:settingsLocation"); + let room = rect(&h, "settings:pageRoom"); + assert!(location.right() <= room.right() + 0.5, "the path {location:?} stays inside the room for the page {room:?}"); + let texts = h.painted_text(); + assert!(!texts.iter().any(|text| text.contains(&path)), "the whole path is not drawn in the row"); + // And what is left of it is its end, which says which library this is + let name = "the-master-library"; + assert!(path.ends_with(name)); + assert!( + texts.iter().any(|text| text.starts_with('…') && text.ends_with(name)), + "the row keeps the end of the path: {:?}", + texts.iter().filter(|t| t.contains("library")).collect::<Vec<_>>() + ); + // And resting the pointer on it shows the whole path + assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true); + assert!(h.step_until(T, |h| h.painted_text().iter().any(|text| text.contains(&path))), "the tooltip has the whole path"); + + // And the same holds for the folder Auto Import watches, which stays on one line + let watched = h.app.run("library.autoImport", json!({"folder": path})); + assert!(watched.is_ok(), "{watched:?}"); + show(&mut h, "import"); + // (the row is at the foot of the page: scrolled to, so that it is drawn however long the page is) + let window = rect(&h, "dialog:window"); + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..6 { + h.request("ui.scroll", json!({"dy": -400.0}), T); + h.step(); + } + h.settle(T); + let (folder, room) = (rect(&h, "text:settingsAutoFolder"), rect(&h, "settings:pageRoom")); + assert!(room.intersects(folder), "the watched folder {folder:?} is in view in {room:?}"); + assert!(folder.height() < 24.0 && folder.right() <= room.right(), "the watched folder {folder:?} is one line inside {room:?}"); + assert!(h.painted_text().iter().any(|text| text.starts_with('…') && text.ends_with(name)), "and keeps its end"); + // And Choose… stays on that line, inside the room, whatever length the path is cut to: the + // path takes what the button leaves (the folders' names vary, so the cut falls everywhere) + for n in 0..14 { + let path = format!("{path}/{}/{name}", "d".repeat(2 + 3 * n)); + std::fs::create_dir_all(&path).unwrap(); + assert!(h.app.run("library.autoImport", json!({"folder": path})).is_ok()); + h.settle(T); + for _ in 0..4 { + h.step(); + } + let (folder, button) = (rect(&h, "text:settingsAutoFolder"), rect(&h, "button:settingsAutoFolder")); + assert!(button.right() <= room.right() + 0.5, "Choose… {button:?} is inside {room:?} (path of {n})"); + assert!((button.center().y - folder.center().y).abs() < 6.0, "Choose… {button:?} is on the line of the path {folder:?} (path of {n})"); + assert!(folder.right() <= button.left(), "the path {folder:?} ends before Choose… {button:?} (path of {n})"); + } + drop(h); +} + +#[test] +fn library_default_stays_on_the_line_of_the_smart_previews_folder_whatever_its_length() { + // Given libraries whose folder names grow in steps (the smart previews folder is `Smart Previews` in + // each, so its path is cut at a different place every time, and some cut leaves less room than the note needs) + let base = TempFolder::new("settings-smart"); + for n in 0..24 { + let dir = base.0.join(format!("photographs-kept-on-the-second-external-drive-{n}")).join("m".repeat(2 + 3 * n)); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&dir, true).unwrap(); + let mut h = headless(session, [1600.0, 1000.0]); + // When Performance is scrolled to the foot of its page + show(&mut h, "performance"); + let window = rect(&h, "dialog:window"); + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..6 { + h.request("ui.scroll", json!({"dy": -400.0}), T); + h.step(); + } + h.settle(T); + // Then "(library default)" stays inside the room, on the line of the path, after it + let room = rect(&h, "settings:pageRoom"); + let (smart, note) = (rect(&h, "text:settingsSmartFolder"), rect(&h, "text:settingsSmartDefault")); + assert!(room.intersects(smart) && room.intersects(note), "the smart previews folder {smart:?} {note:?} is in view in {room:?} (name of {n})"); + assert!(note.right() <= room.right() + 0.5, "(library default) {note:?} is inside {room:?} (name of {n})"); + assert!((note.center().y - smart.center().y).abs() < 6.0, "(library default) {note:?} is on the line of the path {smart:?} (name of {n})"); + assert!(smart.right() <= note.left(), "the path {smart:?} ends before (library default) {note:?} (name of {n})"); + drop(h); + } +} + +#[test] +fn a_short_path_is_shown_whole_and_has_no_tooltip() { + // Given the in-memory library, whose Location is a short sentence + let mut h = demo([1600.0, 1000.0]); + show(&mut h, "general"); + assert!(h.painted_text().iter().any(|text| text == "In memory — nothing is saved"), "it is all there"); + let before = h.painted_text().len(); + // When the pointer rests on it + assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true); + let _ = h.step_until(Duration::from_secs(2), |h| h.painted_text().len() > before); + // Then nothing more is drawn: there is nothing a tooltip could add + assert_eq!(h.painted_text().len(), before, "no tooltip for a path that is all there"); +} + +/// How many pixels of `rect` are not `colour` (one pixel per point here). +fn other_than(image: &egui::ColorImage, rect: egui::Rect, colour: egui::Color32) -> usize { + let [w, h] = image.size; + let (x0, x1) = (rect.left().round().max(0.0) as usize, (rect.right().round().max(0.0) as usize).min(w)); + let (y0, y1) = (rect.top().round().max(0.0) as usize, (rect.bottom().round().max(0.0) as usize).min(h)); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|(x, y)| image.pixels[y * w + x] != colour).count() +} + +#[test] +fn a_page_that_goes_on_below_shows_a_scroll_handle_without_the_pointer_on_it() { + let mut h = demo([900.0, 560.0]); + let chrome = crate::theme::Tokens::get(&h.view.ctx).chrome; + // (the pointer is nowhere near the window) + h.request("ui.move", json!({"x": 5.0, "y": 5.0}), T); + let handle = |h: &Headless| { + let room = rect(h, "settings:pageRoom"); + other_than(&h.paint(), egui::Rect::from_min_max(egui::pos2(room.right() - 6.0, room.top()), room.max), chrome) + }; + // Given Import, which is taller than the room a 560 pt tall app window has for it + show(&mut h, "import"); + assert!(rect(&h, "settings:page").height() > rect(&h, "settings:pageRoom").height()); + // Then a handle is drawn at the page's right edge + assert!(handle(&h) > 40, "Import shows a scroll handle ({} px)", handle(&h)); + // And Display, which fits, has none + show(&mut h, "display"); + assert!(rect(&h, "settings:page").height() <= rect(&h, "settings:pageRoom").height()); + assert_eq!(handle(&h), 0, "Display has nothing to scroll"); +} + +#[test] +fn a_window_too_short_for_a_page_of_its_own_scrolls_tabs_and_page_together() { + // Given an app window far below the desktop minimum + let mut h = demo([900.0, 220.0]); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + show(&mut h, "import"); + let window = rect(&h, "dialog:window"); + assert!(screen.contains_rect(window), "Settings {window:?} is inside the {screen:?} window"); + let (tab, page) = (rect(&h, "button:settingsTab-import"), rect(&h, "check:settings.perCamera")); + // When it is scrolled + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..3 { + h.request("ui.scroll", json!({"dy": -120.0}), T); + h.step(); + } + h.settle(T); + // Then the tabs and the page move as one, so every setting can still be reached + let (tab_now, page_now) = (rect(&h, "button:settingsTab-import"), rect(&h, "check:settings.perCamera")); + assert!(page_now.top() < page.top() - 50.0, "the page scrolled: {page:?} -> {page_now:?}"); + assert!(((tab.top() - tab_now.top()) - (page.top() - page_now.top())).abs() < 0.5, "tabs and page moved together"); + assert!(same(window, rect(&h, "dialog:window")), "the window did not move"); +} + +#[test] +fn the_tabs_stay_put_while_a_page_scrolls() { + // Given a page taller than the window has room for + let mut h = demo([900.0, 560.0]); + show(&mut h, "import"); + let window = rect(&h, "dialog:window"); + let tabs: Vec<egui::Rect> = TABS.iter().map(|(id, _)| rect(&h, &format!("button:settingsTab-{id}"))).collect(); + let page_before = rect(&h, "check:settings.perCamera"); + // When the page is scrolled + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..3 { + h.request("ui.scroll", json!({"dy": -120.0}), T); + h.step(); + } + h.settle(T); + // Then the page moved + let page_after = rect(&h, "check:settings.perCamera"); + assert!(page_after.top() < page_before.top() - 50.0, "the page scrolled: {page_before:?} -> {page_after:?}"); + // And the tabs and the window did not + for ((id, _), before) in TABS.iter().zip(tabs) { + assert!(same(before, rect(&h, &format!("button:settingsTab-{id}"))), "tab {id} moved"); + } + assert!(same(window, rect(&h, "dialog:window")), "the window moved"); +} + +#[test] +fn each_tab_remembers_how_far_it_was_scrolled() { + let mut h = demo([900.0, 560.0]); + show(&mut h, "interface"); + let fresh = rect(&h, "button:settingsAppearance-0"); + // Given Import scrolled down + show(&mut h, "import"); + let window = rect(&h, "dialog:window"); + let top = rect(&h, "check:settings.perCamera").top(); + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..3 { + h.request("ui.scroll", json!({"dy": -120.0}), T); + h.step(); + } + h.settle(T); + let scrolled = rect(&h, "check:settings.perCamera").top(); + assert!(scrolled < top - 50.0); + // When another tab is opened, it starts at its top + show(&mut h, "interface"); + let mode = rect(&h, "button:settingsAppearance-0"); + assert!(same(fresh, mode), "Interface is at its top, as when first opened: {fresh:?} -> {mode:?}"); + // And Import is where it was left + show(&mut h, "import"); + assert!((rect(&h, "check:settings.perCamera").top() - scrolled).abs() < 0.5, "Import is where it was left"); + // And it is still there when Settings is closed and opened again (documented in What's New) + assert_eq!(h.request("ui.dialog.cancel", json!({}), T)["ok"], true); + h.settle(T); + show(&mut h, "import"); + assert!((rect(&h, "check:settings.perCamera").top() - scrolled).abs() < 0.5, "Import is where it was left after a reopen"); +} diff --git a/crates/ui-egui/src/tests_dialogs.rs b/crates/ui-egui/src/tests_dialogs.rs index 6581640ec..2e96b59e7 100644 --- a/crates/ui-egui/src/tests_dialogs.rs +++ b/crates/ui-egui/src/tests_dialogs.rs @@ -85,6 +85,71 @@ fn modal_dialogs_block_background_shortcuts_and_keep_escape() { assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5, "closing restores shortcuts"); } +/// Settings → Close a dialog by clicking outside it: a click on the backdrop closes the dialog +/// like Esc, without reaching the panels below; a click inside it, or a drag that ends outside, keeps it. +#[test] +fn a_click_outside_closes_the_dialog_when_settings_allow_it() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + let active = h.app.session.active().unwrap(); + let rating = h.app.session.catalog.photo(active).unwrap().rating; + h.app.run("dialog.copySettings", json!({})).unwrap(); + h.step(); + // off by default: the background click is blocked and the dialog stays + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_some(), "a stray click keeps the dialog unless Settings say otherwise"); + h.app.ui.settings.close_dialogs_outside = true; + let r = h.request("ui.clickWidget", json!({"id": "copyGroup:none"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_some(), "a click inside the dialog keeps it"); + let window = h.app.widgets.iter().find(|(id, _)| id == "dialog:window").map(|(_, r)| *r).unwrap(); + let r = h.request("ui.drag", json!({"x": window.center().x, "y": window.center().y, "toX": 10.0, "toY": 10.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_some(), "a drag that ends outside the dialog keeps it"); + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "a click on the backdrop closes the dialog"); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, rating, "the click doesn't reach the panel below"); + // the click that opens a dialog lands outside its window; a quick one on a slow frame arrives + // pressed and released in that one frame + let gear = h.app.widgets.iter().find(|(id, _)| id == "icon:copyGear").map(|(_, r)| r.center()).unwrap(); + let press = |pressed| egui::Event::PointerButton { pos: gear, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(gear), press(true), press(false)]); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_some(), "the click that opens a dialog doesn't close it"); + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "the next click on the backdrop does"); +} + +/// With a dropdown open in the dialog, a click on the backdrop only closes the dropdown, even when +/// it arrives pressed and released in one frame (the dropdown closes while the dialog is drawn). +#[test] +fn a_click_outside_an_open_dropdown_keeps_the_dialog() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + h.app.ui.settings.close_dialogs_outside = true; + h.app.run("dialog.export", json!({})).unwrap(); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "combo:exportPreset"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(egui::Popup::is_any_open(&h.view.ctx), "the preset dropdown is open"); + let at = egui::pos2(10.0, 990.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(at), press(true), press(false)]); + h.step(); + h.step(); + assert!(!egui::Popup::is_any_open(&h.view.ctx), "the click closes the dropdown"); + assert!(h.app.ui.dialog.is_some(), "but not the dialog"); +} + #[test] fn modal_dialogs_close_background_menus() { let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); diff --git a/crates/ui-egui/src/tests_drop_import.rs b/crates/ui-egui/src/tests_drop_import.rs new file mode 100644 index 000000000..837d60c76 --- /dev/null +++ b/crates/ui-egui/src/tests_drop_import.rs @@ -0,0 +1,331 @@ +//! Files dragged from the file manager onto the window: while they hover, the window says what +//! dropping will do; dropping photos or folders opens the import review (unless Settings ▸ Import +//! says to import dropped files directly); a drop while a dialog is open is refused. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-ui-drop-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn write_png(path: &str, seed: u8) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(std::path::Path::new(path).parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// An empty library, a folder `trip` with two photos, a window big enough for the overlay. +fn app(dir: &Scratch) -> Headless { + write_png(&dir.path("trip/a.png"), 1); + write_png(&dir.path("trip/b.png"), 2); + let session = lightcraft_engine::Session::new().with_fs(); + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 900.0], 1.0); + for _ in 0..4 { + h.step(); + } + h +} + +fn drop_files(h: &mut Headless, paths: &[String], hover: bool) { + let r = h.request("ui.dropFiles", json!({"paths": paths, "hover": hover}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..3 { + h.step(); + } +} + +fn overlay(h: &Headless) -> bool { + h.app.widgets.iter().any(|(w, _)| w == "dropOverlay") +} + +fn said(h: &Headless, word: &str) -> bool { + h.painted_text().iter().any(|t| t.contains(word)) +} + +fn photos(h: &Headless) -> usize { + h.app.session.catalog.photos().count() +} + +#[test] +fn hovering_photos_says_they_will_be_reviewed_and_lets_go_when_they_leave() { + let dir = Scratch::new("hover"); + let mut h = app(&dir); + assert!(!overlay(&h), "nothing is dragged over the window"); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], true); + assert!(overlay(&h), "the window shows where to drop"); + assert!(said(&h, "review 2 items"), "{:?}", h.painted_text()); + drop_files(&mut h, &[], true); + assert!(!overlay(&h), "the files left the window"); +} + +#[test] +fn hovering_presets_says_they_will_be_imported_as_presets() { + let dir = Scratch::new("hover-presets"); + let mut h = app(&dir); + std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap(); + drop_files(&mut h, &[dir.path("warm.lcpreset")], true); + assert!(overlay(&h)); + assert!(said(&h, "1 preset"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "presets are not reviewed"); +} + +#[test] +fn hovering_unknown_files_says_only_that_dropping_imports() { + let dir = Scratch::new("hover-unknown"); + let mut h = app(&dir); + drop_files(&mut h, &[String::new()], true); + assert!(overlay(&h)); + assert!(said(&h, "Drop to import"), "{:?}", h.painted_text()); +} + +#[test] +fn dropping_a_folder_opens_the_import_review_and_imports_nothing_yet() { + let dir = Scratch::new("review"); + let mut h = app(&dir); + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens"); + let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 2); + assert_eq!(photos(&h), 0, "nothing is imported until the review is confirmed"); + assert!(!overlay(&h), "the files were let go"); +} + +#[test] +fn dropping_imports_directly_when_settings_say_so() { + let dir = Scratch::new("direct"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.step_until(SETTLE, |h| photos(h) == 2 && h.app.import.is_none()), "both photos arrive"); + assert!(h.app.ui.dialog.is_none(), "no review"); +} + +#[test] +fn a_drop_while_a_dialog_is_open_leaves_the_dialog_and_imports_nothing() { + let dir = Scratch::new("dialog"); + let mut h = app(&dir); + h.app.ui.dialog = Some(Dialog::WhatsNew); + drop_files(&mut h, &[dir.path("trip/a.png")], true); + assert!(overlay(&h), "the window still answers the hover"); + assert!(said(&h, "Close the open dialog"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("trip")], false); + for _ in 0..30 { + h.step(); + } + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the open dialog stays"); + assert!(h.app.scan.is_none() && h.app.import.is_none(), "no scan or import started"); + assert_eq!(photos(&h), 0); + assert!(h.app.ui.toast.is_some(), "the person is told why"); +} + +/// The scan of a dropped folder finishing while another dialog is open (the face model's licence, Settings…) +/// keeps that dialog; the review opens, with the scan's photos, once it is closed. +#[test] +fn a_finished_scan_waits_for_the_open_dialog_before_opening_the_review() { + let dir = Scratch::new("waits"); + let mut h = app(&dir); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::WhatsNew); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + for _ in 0..10 { + h.step(); + } + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the dialog the person had open stays"); + h.app.ui.dialog = None; + assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens once the dialog is closed"); + let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 2, "the scan's photos were kept"); +} + +/// A scan that finishes behind Settings, which then opens another library: the review of the first +/// library's scan doesn't open over the second one once Settings closes, and nothing is imported into it. +#[test] +fn a_waiting_review_is_dropped_when_another_library_opens() { + let dir = Scratch::new("switch"); + let mut h = app(&dir); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::Settings { tab: "general".into() }); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + h.step(); + let stale = h.app.review_pending.clone().expect("the review waits behind Settings"); + h.app.run("app.openLibrary", json!({"path": dir.path("other")})).unwrap(); + h.app.ui.dialog = None; + for _ in 0..10 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review from the other library opens"); + assert!(h.app.review_pending.is_none()); + // a review confirmed after the switch (an agent holding it) imports nothing either + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &stale).is_err()); + for _ in 0..10 { + h.step(); + } + assert!(h.app.import.is_none(), "nothing is imported"); + assert_eq!(photos(&h), 0); +} + +/// A review waiting behind another dialog counts as an import in progress: a direct drop meanwhile is +/// refused, and the review's OK never replaces an import that runs when it is confirmed. +#[test] +fn a_waiting_review_never_replaces_a_running_import() { + let dir = Scratch::new("pending-running"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + write_png(&dir.path("more/c.png"), 3); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::WhatsNew); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + h.step(); + assert!(h.app.review_pending.is_some(), "the review waits behind the dialog"); + assert!(crate::import::start_paths(&mut h.app, vec![dir.path("more")]).is_err(), "no second import starts"); + assert!(h.app.import.is_none()); + h.app.ui.dialog = None; + h.step(); + let Some(review) = h.app.ui.dialog.clone() else { panic!("the review opens once the dialog is closed") }; + assert!(matches!(review, Dialog::Import { .. })); + // an import that started anyway (a Local folder browse, say) is the one that runs + h.app.import = Some(crate::import::ImportTask::new(vec![dir.path("more")], json!({"mode": "add"}), 0, false)); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &review).is_err(), "the review's OK is refused"); + assert_eq!(h.app.import.as_ref().map(|t| t.total), Some(1), "the running import stays"); + assert!(h.step_until(SETTLE, |h| h.app.import.is_none()), "it runs to the end"); + assert_eq!(photos(&h), 1, "only the running import's photo arrives"); +} + +/// A drop while an import runs would open a review whose OK replaces the running import: it is +/// refused instead, and the card says so. +#[test] +fn a_drop_while_an_import_runs_is_refused() { + let dir = Scratch::new("running"); + let mut h = app(&dir); + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan(&mut h.app); + drop_files(&mut h, &[dir.path("more/c.png")], true); + assert!(said(&h, "An import is running"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("more")], false); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review opened"); + assert!(h.app.scan.is_some() && h.app.import.is_none(), "the running scan was left alone and nothing else started"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + assert_eq!(photos(&h), 0); +} + +/// An import that is running stays the one that runs: a direct drop doesn't start a second. +#[test] +fn a_direct_drop_while_an_import_runs_is_refused_too() { + let dir = Scratch::new("running-direct"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan(&mut h.app); + drop_files(&mut h, &[dir.path("more")], false); + assert!(h.app.import.is_none(), "no second import"); + assert!(h.app.ui.toast.is_some()); +} + +/// A Local folder browse scan counts too: a review-first drop would be rejected by `open`, and a +/// direct drop would be replaced when the browse completes. +#[test] +fn a_drop_while_a_browse_scan_runs_is_refused_in_both_modes() { + for direct in [false, true] { + let dir = Scratch::new("running-browse"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = direct; + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan_of(&mut h.app, true); + drop_files(&mut h, &[dir.path("more/c.png")], true); + assert!(said(&h, "An import is running"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("more")], false); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none() && h.app.import.is_none(), "direct={direct}: nothing started"); + assert!(h.app.scan.is_some(), "the browse was left alone"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + } +} + +/// While a Lightroom catalog import is pending the drop is refused, and the card says so instead of +/// promising a review. +#[test] +fn hovering_photos_while_a_lightroom_import_is_pending_says_they_will_be_refused() { + let dir = Scratch::new("lightroom"); + let mut h = app(&dir); + let _task = crate::lightroom_import::stall_import(&mut h.app); + drop_files(&mut h, &[dir.path("trip")], true); + assert!(said(&h, "Finish Lightroom catalog import"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.app.ui.dialog.is_none(), "no review opened"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + assert_eq!(photos(&h), 0); +} + +/// A script is not told `ok` for the part of a `paths` list that was strings. +#[test] +fn drop_files_with_a_non_string_path_is_rejected_and_drops_nothing() { + let dir = Scratch::new("badpaths"); + let mut h = app(&dir); + let r = h.request("ui.dropFiles", json!({"paths": [dir.path("trip"), 7]}), T); + assert_eq!(r["ok"], false, "{r}"); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "nothing was dropped"); + assert_eq!(photos(&h), 0); +} + +/// No library open yet (the window of a library that failed to open, or the launch list): the +/// card says the photos wait, and a drop keeps them for the library chosen instead of importing +/// them into the session that library replaces. +#[test] +fn photos_dropped_with_no_library_open_wait_for_one() { + let dir = Scratch::new("no-library"); + let mut h = app(&dir); + h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new(dir.path("Gone"), "not found")); + std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap(); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("warm.lcpreset")], true); + assert!(said(&h, "once a library is open"), "{:?}", h.painted_text()); + assert!(said(&h, "drop the presets again") && !said(&h, "1 preset"), "{:?}", h.painted_text()); + drop_files(&mut h, &[], true); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], false); + assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.len()), Some(2)); + assert!(h.app.ui.dialog.is_none() && h.app.import.is_none(), "no review, no import into the temporary session"); + assert_eq!(photos(&h), 0); + // a preset belongs to a library: refused, not imported into the session the library replaces + h.app.ui.toast = None; + drop_files(&mut h, &[dir.path("warm.lcpreset")], false); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("drop the presets again"), "{toast}"); +} diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs index f93dd4f7b..efd7f5716 100644 --- a/crates/ui-egui/src/tests_focus_point.rs +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -1,4 +1,4 @@ -//! Headless tests of the camera's focus point (#1480): View ▸ Show Focus Point draws the box where the +//! Headless tests of the camera's focus point (#1480): View ▸ Focus Point draws the box where the //! camera focused, through the user's rotation and crop; Toggle Zoom (Z) centres on it; the Info panel //! names the focus mode. diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests_grading.rs index f6903550c..cd1f190cf 100644 --- a/crates/ui-egui/src/tests_grading.rs +++ b/crates/ui-egui/src/tests_grading.rs @@ -375,3 +375,125 @@ fn wheel_rim_is_fully_saturated_and_the_centre_neutral() { let (hi, lo) = (p.r().max(p.g()).max(p.b()), p.r().min(p.g()).min(p.b())); assert!(hi - lo <= 40 && lo >= 100, "centre is not a neutral grey: {p:?}"); } + +// ------------------------------------------------------------------ the held pointer (issue #1899) + +fn pointer_event(h: &mut Headless, e: egui::Event) { + h.events.push(e); + h.step(); +} + +fn button(h: &mut Headless, at: egui::Pos2, pressed: bool) { + let e = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + pointer_event(h, e); +} + +/// Press the midtones wheel at `at` and move the pointer 10 to the right, far enough to make it +/// a drag; where the pointer is then. +fn start_wheel_drag(h: &mut Headless, at: egui::Pos2) -> egui::Pos2 { + pointer_event(h, egui::Event::PointerMoved(at)); + button(h, at, true); + let to = at + egui::vec2(10.0, 0.0); + pointer_event(h, egui::Event::PointerMoved(to)); + h.step(); + to +} + +fn cursor_moves(h: &Headless) -> Vec<egui::Pos2> { + h.window_commands + .iter() + .filter_map(|c| match c { + egui::ViewportCommand::CursorPosition(p) => Some(*p), + _ => None, + }) + .collect() +} + +#[test] +fn a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits() { + use crate::panels::edit::wheel_pick; + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 0.0, 0.0); + let c = widget(&h, "wheel:midtones").unwrap().center(); + let held = start_wheel_drag(&mut h, c + egui::vec2(rad * 0.4, 0.0)); + // the pointer stays where it is (macOS locks it there) while the mouse goes on moving up + for _ in 0..5 { + pointer_event(&mut h, egui::Event::MouseMoved(egui::vec2(0.0, -rad * 0.1))); + } + let (hue, sat) = wheel_value(&h, "midtones"); + let (want_hue, want_sat) = wheel_pick(held + egui::vec2(0.0, -rad * 0.5) - c, rad, None); + assert!((hue - want_hue.round() as f64).abs() <= 1.0 && (sat - want_sat.round() as f64).abs() <= 1.0, "{hue} {sat} vs {want_hue} {want_sat}"); + assert!(hue > 30.0, "the puck went up with the mouse: {hue}"); + button(&mut h, held, false); +} + +#[test] +fn a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 0.0, 0.0); + let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, rad * 0.2); + let at = start_wheel_drag(&mut h, press); + let far = at + egui::vec2(-rad * 0.6, -rad * 0.9); + pointer_event(&mut h, egui::Event::PointerMoved(far)); + assert_eq!(h.last_cursor, egui::CursorIcon::None, "hidden while the wheel is dragged"); + assert!(cursor_moves(&h).is_empty(), "nothing moves the pointer before the release"); + button(&mut h, far, false); + h.step(); + assert_eq!(cursor_moves(&h), [press], "back where the button went down"); + assert_ne!(h.last_cursor, egui::CursorIcon::None, "shown again"); + #[cfg(target_os = "macos")] + assert_eq!( + h.window_commands.iter().filter(|c| matches!(c, egui::ViewportCommand::CursorGrab(_))).collect::<Vec<_>>(), + [&egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked), &egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)], + "locked for the drag, unlocked at the end" + ); + // a click is not a drag: the pointer stays visible where it is + h.window_commands.clear(); + let r = h.request("ui.clickWidget", json!({"id": "wheel:midtones"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + +#[test] +fn escape_or_losing_focus_gives_the_pointer_back_mid_drag() { + let esc = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for let_go in [esc, egui::Event::WindowFocused(false)] { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 0.0, 0.0); + let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, 0.0); + let at = start_wheel_drag(&mut h, press); + assert_eq!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}: hidden while dragged"); + pointer_event(&mut h, let_go.clone()); + h.step(); + assert_ne!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}: shown again"); + assert_eq!(cursor_moves(&h), [press], "{let_go:?}: back where it was pressed"); + // the rest of the drag no longer moves the puck, and the pointer stays visible + let before = wheel_value(&h, "midtones"); + pointer_event(&mut h, egui::Event::PointerMoved(at + egui::vec2(-rad * 0.5, -rad * 0.5))); + assert_eq!(wheel_value(&h, "midtones"), before, "{let_go:?}"); + assert_ne!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}"); + button(&mut h, at, false); + h.step(); + assert_eq!(cursor_moves(&h), [press], "{let_go:?}: given back once"); + } +} + +#[test] +fn a_wheel_that_goes_away_mid_drag_gives_the_pointer_back() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, 0.0); + start_wheel_drag(&mut h, press); + assert_eq!(h.last_cursor, egui::CursorIcon::None); + // the flyout closes under the drag: no wheel is drawn to let go of the pointer itself + h.app.ui.toggle_flyout("grading"); + h.step(); + h.step(); + assert!(widget(&h, "wheel:midtones").is_none()); + assert_ne!(h.last_cursor, egui::CursorIcon::None); + assert_eq!(cursor_moves(&h), [press]); +} diff --git a/crates/ui-egui/src/tests_histogram_drag.rs b/crates/ui-egui/src/tests_histogram_drag.rs new file mode 100644 index 000000000..e6fd7138d --- /dev/null +++ b/crates/ui-egui/src/tests_histogram_drag.rs @@ -0,0 +1,295 @@ +//! The histogram's tonal zones, as in Lightroom Classic: dragging one sideways moves its Basic +//! control (one undo step for the whole drag), the zone locks at the press, a double-click resets +//! that control, and hovering highlights the zone and names it in the readout under the plot. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The zones' controls, left to right (`panels::histogram::ZONES`). +const CONTROLS: [&str; 5] = ["light.blacks", "light.shadows", "light.exposure", "light.highlights", "light.whites"]; + +/// The demo library in Detail with the Edit panel (the histogram sits first) on a selected photo. +fn edit_panel() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let id = h.app.session.visible_cloned()[0].0; + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> Option<egui::Rect> { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + widget(h, id).unwrap_or_else(|| panic!("no widget {id} on screen")) +} + +fn value(h: &Headless, control: &str) -> f64 { + let id = h.app.session.active().unwrap_or_else(|| panic!("no active photo")); + let spec = lightcraft_develop::controls::find(control).unwrap_or_else(|| panic!("no control {control}")); + lightcraft_develop::controls::get(&h.app.session.develop_of(id).unwrap_or_default(), control).unwrap_or(spec.default) +} + +/// Every zone's control, left to right. +fn values(h: &Headless) -> Vec<f64> { + CONTROLS.iter().map(|c| value(h, c)).collect() +} + +/// Set a control through the engine, as a slider would. +fn set(h: &mut Headless, control: &str, v: f64) { + h.app.run("develop.set", json!({"control": control, "value": v})).unwrap_or_else(|e| panic!("develop.set {control}: {e}")); +} + +/// Let the harness clock pass `seconds`, running frames. egui closes its double-click window when +/// the next click comes more than 0.3 s later *and* more than 6 points away; its triple-click +/// window is twice that, and two clicks in another zone right after two in one are close enough to +/// the second click to count as a triple click of it — which no longer reports `double_clicked`. +fn after(h: &mut Headless, seconds: f64) { + let until = h.time() + seconds; + while h.time() < until { + h.step(); + } +} + +/// Drag from a fraction `fx` across the histogram: `fy` 0.5 is inside the plot, `dx` in points. +fn drag(h: &mut Headless, fx: f32, dx: f32, shift: bool) { + let p = json!({"id": "histogram", "fx": fx, "fy": 0.5, "dx": dx, "steps": 8, "shift": shift}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); +} + +/// A double-click on the histogram at `fx` (the plot's own rows, `fy` 0.5). +fn double_click(h: &mut Headless, fx: f32) { + let r = h.request("ui.clickWidget", json!({"id": "histogram", "fx": fx, "fy": 0.5, "count": 2}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// Hover the histogram at `fx` and let the frame settle. +fn hover(h: &mut Headless, fx: f32) { + let r = h.request("ui.hoverWidget", json!({"id": "histogram", "fx": fx, "fy": 0.5}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +/// Dragging one zone moves that control: a sideways travel of the plot's width sweeps its whole +/// range, and the whole drag is one undo step. +#[test] +fn dragging_the_exposure_zone_moves_the_exposure_in_one_undo_step() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + drag(&mut h, 0.5, w * 0.05, false); + // a twentieth of the plot: a twentieth of ±5 EV + let exposure = value(&h, "light.exposure"); + assert!((exposure - 0.5).abs() <= 0.02, "a twentieth of the plot is a twentieth of the range: {exposure}"); + assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step for the whole drag"); + assert!(h.app.session.interaction.is_none(), "the interaction is closed on the release"); + assert!(h.app.ui.dragging_control.is_none(), "no control is left dragged"); + let mut others = values(&h); + others.remove(2); + assert_eq!(others, [0.0, 0.0, 0.0, 0.0], "the zones the drag crossed are untouched"); + // one undo brings the exposure back + let r = h.request("engine.execute", json!({"command": "edit.undo"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(value(&h, "light.exposure"), 0.0); +} + +/// The zone a press landed in stays locked for the whole drag: dragging from Exposure across the +/// Shadows and Blacks zones still only moves the Exposure. +#[test] +fn the_zone_locks_at_the_press_however_far_the_drag_travels() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + drag(&mut h, 0.5, -w * 0.4, false); + let exposure = value(&h, "light.exposure"); + assert!((exposure + 4.0).abs() <= 0.02, "four tenths of the plot to the left is −4 EV: {exposure}"); + assert_eq!((value(&h, "light.shadows"), value(&h, "light.blacks")), (0.0, 0.0), "the zones crossed stay untouched"); + assert_eq!(h.app.session.undo.len() - undo, 1, "still one undo step"); +} + +/// Dragging left lowers the zone's control and dragging right raises it; a drag past either end +/// clamps at that control's range (the zones' own specs, ±100 for the four, ±5 EV for Exposure). +#[test] +fn a_drag_lowers_and_clamps_at_the_controls_range() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + drag(&mut h, 0.5, -w * 0.2, false); + assert!((value(&h, "light.exposure") + 2.0).abs() <= 0.02, "{}", value(&h, "light.exposure")); + // the Blacks zone, dragged right across the whole plot and beyond: clamped at +100 + drag(&mut h, 0.08, w * 0.9, false); + assert_eq!(value(&h, "light.blacks"), 100.0); + drag(&mut h, 0.92, -w * 2.0, false); + assert_eq!(value(&h, "light.whites"), -100.0); +} + +/// Shift is a tenth of the travel, as on the sliders. +#[test] +fn shift_drags_a_tenth_as_far() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + drag(&mut h, 0.5, w * 0.1, false); + let plain = value(&h, "light.exposure"); + assert!((plain - 1.0).abs() <= 0.02, "{plain}"); + set(&mut h, "light.exposure", 0.0); + drag(&mut h, 0.5, w * 0.1, true); + let fine = value(&h, "light.exposure"); + assert!((fine - 0.1).abs() <= 0.02, "a tenth of {plain}: {fine}"); +} + +/// A double-click in a zone puts that zone's control back to its default in one undo step, leaving +/// the other zones' controls alone. +#[test] +fn double_clicking_a_zone_resets_that_control_in_one_undo_step() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + set(&mut h, "light.shadows", 40.0); + let undo = h.app.session.undo.len(); + double_click(&mut h, 0.5); + assert_eq!(value(&h, "light.exposure"), 0.0, "the Exposure zone is reset"); + assert_eq!(value(&h, "light.shadows"), 40.0, "only the zone that was clicked"); + assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step"); + // the Shadows zone (0.15–0.35) resets its own control, once the double-click window has passed + after(&mut h, 0.7); + double_click(&mut h, 0.25); + assert_eq!(value(&h, "light.shadows"), 0.0); + // a click on the histogram toggles the clipping warnings (as before this feature): the two + // clicks of a double-click toggle them twice, so the state is where it started + assert!(!h.app.ui.show_clipping); +} + +/// The strip under the plot — the readout and the EXIF line — is not a zone: a drag there leaves +/// every control alone. +#[test] +fn a_drag_below_the_plot_leaves_the_tones_alone() { + let mut h = edit_panel(); + let w = rect(&h, "histogram").width(); + let undo = h.app.session.undo.len(); + let p = json!({"id": "histogram", "fx": 0.5, "fy": 0.97, "dx": w * 0.2, "steps": 6}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "no zone was dragged"); + assert_eq!(h.app.session.undo.len(), undo, "and nothing was recorded"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); +} + +/// A drag that starts on a clipping chevron (shadows or highlights) changes no develop tone: the +/// chevrons are clipping controls, not tonal zones, even though they sit inside the plot. +#[test] +fn a_drag_starting_on_a_clipping_chevron_changes_no_develop_value() { + let mut h = edit_panel(); + let undo = h.app.session.undo.len(); + // Drag starting on the shadow clipping indicator (which sits over the Blacks zone) + let p = json!({"id": "histogram:shadows", "dx": 40.0, "steps": 8}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "dragging from the shadow chevron moves no tone"); + assert_eq!(h.app.session.undo.len(), undo, "and leaves undo alone"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); + + // Drag starting on the highlight clipping indicator (which sits over the Whites zone) + let p = json!({"id": "histogram:highlights", "dx": -40.0, "steps": 8}); + let r = h.request("ui.dragWidget", p.clone(), T); + assert_eq!(r["ok"], true, "{p}: {r}"); + h.settle(SETTLE); + assert_eq!(values(&h), [0.0; 5], "dragging from the highlight chevron moves no tone"); + assert_eq!(h.app.session.undo.len(), undo, "undo remains untouched"); + assert!(h.app.session.interaction.is_none(), "no interaction was opened"); +} + +/// Hovering a zone highlights it (the band covers that zone's share of the plot) and names it with +/// its value in the readout; off the histogram both are gone. +#[test] +fn hovering_a_zone_highlights_it_and_shows_its_name_and_value() { + let mut h = edit_panel(); + let hist = rect(&h, "histogram"); + set(&mut h, "light.shadows", 25.0); + hover(&mut h, 0.25); + let band = rect(&h, "histogram:zone"); + let share = |x: f32| hist.left() + hist.width() * x; + assert!(band.left() >= share(0.14) && band.right() <= share(0.36), "the Shadows band: {band:?} in {hist:?}"); + assert!(h.painted_text().iter().any(|t| t == "Shadows: +25"), "{:?}", h.painted_text()); + // the Exposure zone, with its EV unit + hover(&mut h, 0.5); + let band = rect(&h, "histogram:zone"); + assert!(band.left() >= share(0.34) && band.right() <= share(0.66), "the Exposure band: {band:?}"); + assert!(h.painted_text().iter().any(|t| t == "Exposure: 0.00 EV"), "{:?}", h.painted_text()); + // the pointer off the histogram: no band, no zone readout (the EXIF line is back) + let r = h.request("ui.move", json!({"x": 600.0, "y": 500.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(widget(&h, "histogram:zone").is_none(), "the highlight is gone"); + assert!(!h.painted_text().iter().any(|t| t.starts_with("Shadows: ")), "{:?}", h.painted_text()); +} + +/// The pointer over the histogram says the zones can be dragged sideways; elsewhere the canvas' +/// own cursor is back. +#[test] +fn the_cursor_over_the_histogram_offers_a_sideways_drag() { + let mut h = edit_panel(); + hover(&mut h, 0.5); + assert_eq!(h.last_cursor, egui::CursorIcon::ResizeHorizontal); + let r = h.request("ui.move", json!({"x": 600.0, "y": 500.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_ne!(h.last_cursor, egui::CursorIcon::ResizeHorizontal); +} + +/// A drag dispatches as it goes, with the interaction held open, so the draft render follows it +/// live; nothing is committed until the release, which lands the whole drag as one undo step. +#[test] +fn a_drag_dispatches_live_and_commits_on_the_release() { + let mut h = edit_panel(); + let before = h.app.session.undo.len(); + let hist = rect(&h, "histogram"); + let from = egui::pos2(hist.left() + hist.width() * 0.5, hist.top() + hist.height() * 0.5); + let to = from + egui::vec2(hist.width() * 0.1, 0.0); + // a press and its moves with no release: the drag is still going when the frames below run + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + for i in 1..=6 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + } + for _ in 0..12 { + h.step(); + } + let exposure = value(&h, "light.exposure"); + assert!(exposure > 0.4, "the exposure followed the drag while it was still going: {exposure}"); + assert!(h.app.session.interaction.is_some(), "the drag holds the interaction open"); + assert_eq!(h.app.ui.dragging_control.as_deref(), Some("light.exposure"), "the drag names the control it moves, as the sliders do"); + assert_eq!(h.app.session.undo.len(), before, "nothing is committed mid-drag"); + // the release: one undo step for the whole drag, and the interaction is closed + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.settle(SETTLE); + assert!(h.app.session.interaction.is_none(), "the release commits the interaction"); + assert!(h.app.ui.dragging_control.is_none(), "and no control is left dragged"); + assert_eq!(h.app.session.undo.len() - before, 1, "the whole drag is one undo step"); +} diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 92997d941..b48da7cdf 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -453,3 +453,51 @@ fn cmd_o_does_not_fire_while_a_dialog_is_open() { press(&mut h, "O", &["cmd"]); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); } + +/// The crop aspect lock is a first-class row of Help - Keyboard Shortcuts: A, listed in the +/// editor like any other command, sharing the key with Visualize Spots as a contextual pair. +#[test] +fn lock_aspect_ratio_is_a_listed_row_on_a() { + use crate::shortcuts::{CONTEXTUAL, Keymap, bindable, shortcut_of}; + let b = bindable().iter().find(|b| b.id == "crop.lockAspect").expect("crop.lockAspect is mappable"); + assert_eq!(b.label, "Lock Aspect Ratio"); + assert_eq!(b.default, Some("A")); + assert_eq!(shortcut_of(&Keymap::new(), "crop.lockAspect"), Some("A")); + assert!(CONTEXTUAL.contains(&("view.visualizeSpots", "crop.lockAspect")), "A is disambiguated by context"); + // the editor lists it: the row is on screen and records keys for it + let mut h = demo(); + record(&mut h, "lock", "crop.lockAspect"); + key(&mut h, "Escape", false); + assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "crop.lockAspect"), Some("A"), "Esc cancels the recording: A stays"); + key(&mut h, "Escape", false); + assert!(h.app.ui.dialog.is_none()); +} + +/// A is the aspect lock while the Crop panel is open — Visualize Spots, the other A, must not take +/// the panel or turn its view on — and outside the panel A opens Visualize Spots as usual. The lock +/// is the tool's own (a fresh photo starts locked to its shape and the toggle may only record +/// "free", leaving the raw develop aspect untouched), so the test reads `crop_tool_aspect`. +#[test] +fn a_is_the_aspect_lock_under_crop_and_visualize_spots_elsewhere() { + use crate::state::RightPanel; + let mut h = demo(); + let aspect = |h: &Headless| h.app.crop_tool_aspect(); + let spots_before = h.app.ui.visualize_spots; + h.app.run("panel.crop", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(h.app.ui.right, RightPanel::Crop); + let before = aspect(&h); + key(&mut h, "A", false); + assert_eq!(h.app.ui.right, RightPanel::Crop, "Visualize Spots must not take the panel"); + assert_eq!(h.app.ui.visualize_spots, spots_before, "nor turn its view on"); + assert_ne!(aspect(&h), before, "A toggles the aspect lock"); + key(&mut h, "A", false); + assert_eq!(aspect(&h), before, "A toggles it back"); + // outside the panel the same key is Visualize Spots again + h.app.ui.right = RightPanel::None; + key(&mut h, "A", false); + assert_eq!(h.app.ui.right, RightPanel::Remove, "A opens Remove with the visualization"); + assert!(h.app.ui.visualize_spots, "Visualize Spots is on"); + assert_eq!(aspect(&h), before, "and the aspect is untouched"); +} diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 95b02967c..076f25789 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -196,6 +196,38 @@ fn the_active_photo_alone_is_the_selection() { assert!(has(&h, "keywordCheck:beach")); } +/// Given a selection with a keyword that is inside others, when the Keyword List is shown, then +/// every keyword above it carries a dot in its box (also folded, where the keyword itself is out +/// of sight), and its own box is the one that is ticked. A keyword above it that the selection +/// has itself shows its tick and no dot; a keyword with nothing applied inside shows neither. +#[test] +fn a_keyword_shows_that_one_inside_it_is_applied() { + let (mut h, ids) = two_selected(); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["travel|Italy|Rome"]}})); + h.settle(SETTLE); + // folded: `travel` is the only row of the three, and says there is something inside + assert!(has(&h, "keywordRow:travel") && !has(&h, "keywordRow:travel|Italy"), "folded"); + assert!(has(&h, "keywordInside:travel")); + assert!(!has(&h, "keywordInside:beach") && !has(&h, "keywordInside:Weddings"), "nothing is inside these"); + // the dot sits in the keyword's own box + let rect = |h: &Headless, id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap(); + assert!(rect(&h, "keywordCheck:travel").contains_rect(rect(&h, "keywordInside:travel"))); + // opened down to it: each level above has the dot, the keyword itself the tick + h.app.ui.keyword_list_open.extend(["travel".to_string(), "travel|italy".to_string()]); + h.settle(SETTLE); + assert!(has(&h, "keywordInside:travel") && has(&h, "keywordInside:travel|Italy")); + assert!(has(&h, "keywordRow:travel|Italy|Rome") && !has(&h, "keywordInside:travel|Italy|Rome")); + // a keyword the selection has itself shows its own state: the tick, or the dash when only some photos have it + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["travel"]}})); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[0]], "addKeywords": ["travel|Italy"]}})); + h.settle(SETTLE); + assert!(!has(&h, "keywordInside:travel") && !has(&h, "keywordInside:travel|Italy")); + // with nothing selected nothing is applied, inside or not + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": []}})); + h.settle(SETTLE); + assert!(has(&h, "keywordRow:travel") && !h.app.widgets.iter().any(|(w, _)| w.starts_with("keywordInside:"))); +} + /// Esc in the Add keyword box gives back what it held before this edit, as every text field does: /// text left there by an earlier edit stays. #[test] diff --git a/crates/ui-egui/src/tests_launch_library.rs b/crates/ui-egui/src/tests_launch_library.rs new file mode 100644 index 000000000..e99ee8d44 --- /dev/null +++ b/crates/ui-egui/src/tests_launch_library.rs @@ -0,0 +1,1017 @@ +//! Issue #1290: what opens at launch. Settings ▸ General ▸ At launch opens the last library or +//! asks with a window listing the recent ones (greyed out with the reason when they can't be +//! opened); Alt/Option held as the window appears asks once; one recent entry can be forgotten from +//! File ▸ Open Recent Library, the window and `app.recentLibraries {remove}`. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::library_problem::{ALT_SECONDS, Launch, LibraryProblem, launch_plan}; +use crate::state::{AppSettings, LaunchLibrary}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// A fresh scratch folder for one test. +fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-ui-launch-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A library on disk at `dir` (written, then closed: as left by an earlier run). +fn make_library(dir: &Path) -> String { + let mut s = lightcraft_engine::Session::new().with_fs(); + s.open_library(dir, false).unwrap(); + s.close_library().unwrap(); + drop(s); + assert!(lightcraft_engine::library::is_library(dir)); + dir.to_string_lossy().into_owned() +} + +/// Enough frames for a synthetic click (move, press, release) and what it starts. +fn settle(h: &mut Headless) { + for _ in 0..6 { + h.step(); + } +} + +/// The widget `id` was drawn in the last frame. +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +/// `Some(s)` as a path, for the launch plan's arguments. +fn path(s: &str) -> Option<PathBuf> { + Some(PathBuf::from(s)) +} + +/// `--library`, then `LIGHTCRAFT_LIBRARY`, then the setting; `--memory` never asks. +#[test] +fn the_launch_plan_puts_the_flag_and_the_variable_before_the_setting() { + let ask = AppSettings { at_launch: LaunchLibrary::Ask, library_path: "/last".into(), ..AppSettings::default() }; + let last = AppSettings { library_path: "/last".into(), recent_libraries: vec!["/last".into(), "/older".into()], ..AppSettings::default() }; + let fresh = AppSettings::default(); + let open = |dir: &str, alt_asks| Launch::Open { dir: path(dir), alt_asks }; + // --library, then LIGHTCRAFT_LIBRARY, whatever the setting says; neither asks on Alt + for s in [None, Some(&ask), Some(&last)] { + assert_eq!(launch_plan(false, path("/flag"), path("/env"), s, path("/default")), open("/flag", false)); + assert_eq!(launch_plan(false, None, path("/env"), s, path("/default")), open("/env", false)); + } + // Ask me asks, with or without a recent list + assert_eq!(launch_plan(false, None, None, Some(&ask), path("/default")), Launch::Ask); + let ask_empty = AppSettings { at_launch: LaunchLibrary::Ask, ..AppSettings::default() }; + assert_eq!(launch_plan(false, None, None, Some(&ask_empty), None), Launch::Ask); + // Open the last library: the last one, else the default place; Alt can still ask + assert_eq!(launch_plan(false, None, None, Some(&last), path("/default")), open("/last", true)); + assert_eq!(launch_plan(false, None, None, Some(&fresh), path("/default")), open("/default", true)); + assert_eq!(launch_plan(false, None, None, None, path("/default")), open("/default", true)); + assert_eq!(launch_plan(false, None, None, None, None), Launch::Open { dir: None, alt_asks: true }); + // --memory never asks and opens no folder + assert_eq!(launch_plan(true, None, None, Some(&ask), path("/default")), Launch::Open { dir: None, alt_asks: false }); + assert_eq!(launch_plan(true, path("/flag"), None, Some(&last), None), Launch::Open { dir: None, alt_asks: false }); +} + +/// Each listed library is checked when the window opens; the last one opened is selected. +#[test] +fn the_list_checks_each_library_and_selects_the_last_one() { + let dir = temp("choose"); + let (a, b, held, plain) = (dir.join("A"), dir.join("B"), dir.join("Held"), dir.join("Plain")); + let (a, b, held_path) = (make_library(&a), make_library(&b), make_library(&held)); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("notes.txt"), b"not a library").unwrap(); + let file = dir.join("a-file.txt"); + std::fs::write(&file, b"a file").unwrap(); + let (gone, plain, file) = + (dir.join("Gone").to_string_lossy().into_owned(), plain.to_string_lossy().into_owned(), file.to_string_lossy().into_owned()); + // another program has it open: listing doesn't take locks, so it is found when opened + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap(); + + let mut settings = AppSettings { + recent_libraries: vec![gone.clone(), held_path.clone(), a.clone(), plain.clone(), file.clone(), b.clone()], + library_path: b.clone(), + ..AppSettings::default() + }; + settings.recent_opened.insert(b.clone(), "2026-10-20T08:30:00".into()); + let p = LibraryProblem::choose(&settings); + assert!(!p.saves_nothing(), "the launch list is not a temporary session: removals are saved"); + let c = p.chooser.as_ref().unwrap(); + let why: Vec<Option<&str>> = c.libraries.iter().map(|l| l.unavailable.as_deref()).collect(); + assert!(why[0].unwrap().contains("Not found"), "{why:?}"); + assert_eq!(why[1], None, "a lock is not probed: {why:?}"); + assert_eq!(why[2], None); + assert!(why[3].unwrap().contains("no LightCraft library"), "{why:?}"); + assert!(why[4].unwrap().contains("not a library folder"), "{why:?}"); + assert_eq!(why[5], None); + assert_eq!(c.selected, Some(5), "the last library opened"); + assert_eq!(c.libraries[5].opened.as_deref(), Some("2026-10-20T08:30:00")); + assert_eq!(c.libraries[2].opened, None, "no time known"); + let json = p.to_json(); + assert_eq!(json["chooser"]["libraries"][0]["available"], false); + assert_eq!(json["chooser"]["selected"], 5); + + // the last library can't be opened: the first one that can is selected + settings.library_path = plain.clone(); + assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(1)); + // none can: the first row, Open greyed out + settings.recent_libraries = vec![gone.clone(), file.clone()]; + assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(0)); + // an empty list selects nothing + settings.recent_libraries.clear(); + let c = LibraryProblem::choose(&settings).chooser.unwrap(); + assert!(c.libraries.is_empty() && c.selected.is_none()); + assert!(LibraryProblem::new("/x", "e").saves_nothing(), "the failed-library window still saves nothing"); + drop(lock); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Every file under `dir`: relative path → (contents, modification time). +fn snapshot(dir: &Path) -> std::collections::BTreeMap<PathBuf, (Vec<u8>, std::time::SystemTime)> { + let mut out = std::collections::BTreeMap::new(); + let mut todo = vec![dir.to_path_buf()]; + while let Some(d) = todo.pop() { + for e in std::fs::read_dir(&d).unwrap() { + let p = e.unwrap().path(); + let m = std::fs::metadata(&p).unwrap(); + if m.is_dir() { + todo.push(p.clone()); + } + let bytes = if m.is_file() { std::fs::read(&p).unwrap() } else { Vec::new() }; + out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, m.modified().unwrap())); + } + } + out +} + +/// Listing the recent libraries (and drawing the window) writes nothing into any of them: no +/// lock file, no owner file, nothing modified — free, held by another program or not a library. +#[test] +fn listing_the_libraries_leaves_every_folder_byte_identical() { + let dir = temp("readonly"); + let (free, fresh, held) = (dir.join("Free"), dir.join("Fresh"), dir.join("Held")); + let (free, held_path) = (make_library(&free), make_library(&held)); + // a library whose lock files were never made (copied over from elsewhere, say) + let fresh_path = make_library(&fresh); + for f in [lightcraft_catalog::lock::LOCK, lightcraft_catalog::lock::OWNER] { + let _ = std::fs::remove_file(fresh.join(f)); + } + std::fs::create_dir_all(dir.join("Plain")).unwrap(); + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap(); + let recent = vec![free.clone(), fresh_path.clone(), held_path.clone(), dir.join("Plain").to_string_lossy().into_owned()]; + let before = snapshot(&dir); + // (mtime granularity: a write in the same instant would still change contents or add files) + let settings = AppSettings { recent_libraries: recent.clone(), library_path: free.clone(), ..AppSettings::default() }; + let p = LibraryProblem::choose(&settings); + assert_eq!(snapshot(&dir), before, "checking the rows must not create or change any file"); + assert!(p.chooser.as_ref().unwrap().libraries.iter().take(3).all(|l| l.unavailable.is_none())); + let mut h = asking(recent, &free, None); + settle(&mut h); + assert!(has(&h, "libraryRecent:2")); + drop(h); + assert_eq!(snapshot(&dir), before, "listing must not create or change any file"); + assert!(!fresh.join(lightcraft_catalog::lock::LOCK).exists() && !fresh.join(lightcraft_catalog::lock::OWNER).exists()); + drop(lock); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The app as the desktop host starts it with *Ask me*: no library open, the list showing. +fn asking(recent: Vec<String>, last: &str, picked: Option<String>) -> Headless { + let session = lightcraft_engine::Session::new().with_fs(); + // (an encoder for the screenshots LIGHTCRAFT_TEST_SHOTS asks for) + let png = |img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + }; + let services = 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() + }; + let mut app = LightcraftApp::new(session, services); + app.ui.settings.recent_libraries = recent; + app.ui.settings.library_path = last.to_string(); + app.ui.settings.at_launch = LaunchLibrary::Ask; + if let Some(p) = picked { + app.services.pick_folder = Some(Box::new(move || Some(p.clone()))); + } + app.library_problem = Some(LibraryProblem::choose(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + h +} + +/// The window lists every recent library, greys out a missing one and opens the chosen one. +#[test] +fn the_window_lists_the_libraries_and_opens_the_chosen_one() { + let dir = temp("window"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut h = asking(vec![a.clone(), gone.clone(), b.clone()], &a, None); + assert!(h.app.session.library.is_none(), "nothing opened before the choice"); + for id in ["libraryRecent:0", "libraryRecent:1", "libraryRecent:2", "button:libraryOpen", "button:libraryOpenAnother", "button:libraryNew"] { + assert!(has(&h, id), "{id}"); + } + assert!(has(&h, "button:libraryQuit") && has(&h, "button:libraryForget")); + assert!(!has(&h, "button:libraryRetry"), "not the failed-library window"); + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": Path::new(&p).join("launch-list.png").to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } + let inspect = h.request("ui.inspect", json!({}), T); + let c = &inspect["result"]["libraryProblem"]["chooser"]; + assert_eq!(c["selected"], 0, "{c}"); + assert_eq!(c["libraries"][1]["available"], false, "{c}"); + + // ↓ onto the missing one: Return does nothing there (Open is greyed out) + assert_eq!(h.request("ui.key", json!({"key": "ArrowDown"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert!(h.app.session.library.is_none() && h.app.library_problem.is_some()); + // ↓ again, then Open: Beta opens and the window goes away + h.request("ui.key", json!({"key": "ArrowDown"}), T); + settle(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(b.clone())); + assert!(h.app.library_problem.is_none()); + assert!(!has(&h, "button:libraryOpen")); + assert_eq!(h.app.ui.settings.library_path, b); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Return opens the selection; a library taken meanwhile stays listed with the reason. +#[test] +fn return_opens_the_selected_library_and_a_failure_says_why() { + let dir = temp("return"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let mut h = asking(vec![a.clone(), b.clone()], &b, None); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + // another program takes Beta after the window opened: Open says so and keeps the window + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&b), "another LightCraft").unwrap(); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert!(h.app.session.library.is_none()); + let p = h.app.library_problem.as_ref().unwrap(); + assert!(p.error.contains("already open in another LightCraft"), "who has it: {}", p.error); + assert!(p.chooser.as_ref().unwrap().libraries[1].unavailable.is_some(), "greyed out now"); + drop(lock); + // ↑ to Alpha, Return: it opens + h.request("ui.key", json!({"key": "ArrowUp"}), T); + settle(&mut h); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(a)); + assert!(h.app.library_problem.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Quit and Esc quit without opening anything. +#[test] +fn quit_and_esc_quit_from_the_window() { + let dir = temp("quit"); + let a = make_library(&dir.join("Alpha")); + let mut h = asking(vec![a.clone()], &a, None); + h.request("ui.key", json!({"key": "Escape"}), T); + settle(&mut h); + assert!(h.quit_requested(), "Esc quits when no library is open"); + assert!(h.app.session.library.is_none(), "and opens nothing"); + drop(h); + + let mut h = asking(vec![a.clone()], &a, None); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryQuit"}), T)["ok"], true); + settle(&mut h); + assert!(h.quit_requested()); + assert!(h.app.session.library.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Open Another… runs the folder dialog of `app.openLibrary`, also with an empty list. +#[test] +fn open_another_asks_for_a_folder_and_an_empty_list_still_offers_it() { + let dir = temp("another"); + let other = make_library(&dir.join("Elsewhere")); + let mut h = asking(Vec::new(), "", Some(other.clone())); + assert!(has(&h, "indicator:noRecentLibraries")); + assert!(!has(&h, "libraryRecent:0")); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(other.clone())); + assert!(h.app.library_problem.is_none()); + assert_eq!(h.app.ui.settings.recent_libraries.first(), Some(&other), "remembered once opened"); + drop(h); + + // without a folder dialog (the browser) Open Another… and New Library… are greyed out + let mut h = asking(Vec::new(), "", None); + h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T); + h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T); + settle(&mut h); + assert!(h.app.library_problem.is_some() && h.app.ui.dialog.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// New Library… asks for a name in front of the window; command-line photos go into it. +#[test] +fn new_library_from_the_window_asks_for_a_name_and_the_list_returns_if_cancelled() { + let dir = temp("newlib"); + let a = make_library(&dir.join("Alpha")); + let mut h = asking(vec![a.clone()], &a, Some(dir.to_string_lossy().into_owned())); + // a photo given on the command line waits for the library chosen + 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(); + h.app.library_problem.as_mut().unwrap().pending_import = vec![photo.to_string_lossy().into_owned()]; + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T)["ok"], true); + settle(&mut h); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::TextPrompt { .. })), "{:?}", h.app.ui.dialog); + assert!(!has(&h, "button:libraryOpen"), "the name prompt is in front"); + h.request("ui.dialog.cancel", json!({}), T); + settle(&mut h); + assert!(has(&h, "button:libraryOpen"), "the list is back"); + // what the prompt's OK runs: the library is made where the folder dialog says, and opens + h.app.run("app.newLibrary", json!({"name": "Fresh"})).unwrap(); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(dir.join("Fresh"))); + assert!(h.app.library_problem.is_none()); + h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1); + assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the new library"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Remove from List in the window forgets the selected entry and deletes nothing. +#[test] +fn remove_from_list_forgets_one_library_and_deletes_nothing() { + let dir = temp("forget"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut h = asking(vec![a.clone(), gone.clone(), b.clone()], &a, None); + h.app.ui.settings.recent_opened.insert(gone.clone(), "2026-01-01T00:00:00".into()); + // the window: the missing one can be selected and removed + h.request("ui.clickWidget", json!({"id": "libraryRecent:1"}), T); + settle(&mut h); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryForget"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.ui.settings.recent_libraries, [a.clone(), b.clone()]); + assert!(!h.app.ui.settings.recent_opened.contains_key(&gone), "its time goes too"); + let c = h.app.library_problem.as_ref().unwrap().chooser.clone().unwrap(); + assert_eq!(c.libraries.len(), 2); + assert_eq!(c.selected, Some(1)); + // removing an existing library leaves its folder and catalog alone + h.request("ui.clickWidget", json!({"id": "button:libraryForget"}), T); + settle(&mut h); + assert_eq!(h.app.ui.settings.recent_libraries, vec![a.clone()]); + assert!(lightcraft_engine::library::is_library(Path::new(&b)), "nothing is deleted on disk"); + settle(&mut h); + assert!(!has(&h, "libraryRecent:1")); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Open Recent Library's items. +fn open_recent(app: &LightcraftApp) -> Vec<crate::menubar::MenuNode> { + use crate::menubar::MenuNode; + let bar = crate::menubar::menu_bar(app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + match file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Open Recent Library")) { + Some(MenuNode::Submenu { children, .. }) => children.clone(), + _ => panic!("no Open Recent Library submenu in {file:?}"), + } +} + +/// File ▸ Open Recent Library ▸ Remove from List and `app.recentLibraries {remove}`. +#[test] +fn open_recent_library_and_the_command_remove_one_entry() { + use crate::menubar::MenuNode; + let dir = temp("menu"); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + let (first, gone) = (dir.join("first").to_string_lossy().into_owned(), dir.join("gone").to_string_lossy().into_owned()); + app.ui.settings.recent_libraries = vec![first.clone(), gone.clone(), "/elsewhere/third".into()]; + + let items = open_recent(&app); + let Some(MenuNode::Submenu { children, .. }) = items.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Remove from List")) + else { + panic!("no Remove from List in {items:?}") + }; + assert_eq!(children.len(), 3, "every entry, missing ones too"); + let MenuNode::Item { id, params, label, enabled, .. } = &children[1] else { panic!("{children:?}") }; + assert_eq!((id.as_str(), params, *enabled), ("app.recentLibraries", &json!({"remove": gone}), true)); + assert!(label.starts_with("gone — "), "{label}"); + assert_eq!(crate::menubar::display_item_label(id, params, label), label, "a path is never translated"); + // what clicking it runs + let list = app.run(id, params.clone()).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + assert_eq!(app.ui.settings.recent_libraries, [first.clone(), "/elsewhere/third".to_string()]); + + // the command: the open library can be forgotten too (it stays open), and nothing is deleted + let list = app.run("app.recentLibraries", json!({"remove": first})).unwrap(); + // (no frame ran: nothing checked yet, so `exists` is not known; the command never looks itself) + assert_eq!(list, json!([{"path": "/elsewhere/third", "name": "third", "exists": null, "checking": false, "open": false, "lastOpened": null}])); + assert!(app.session.library.is_some() && lightcraft_engine::library::is_library(&dir.join("first"))); + let e = app.run("app.recentLibraries", json!({"remove": first})).unwrap_err(); + assert!(e.contains("not in the recent libraries"), "{e}"); + let e = app.run("app.recentLibraries", json!({"remove": 3})).unwrap_err(); + assert!(e.contains("remove must be"), "{e}"); + app.run("app.recentLibraries", json!({"remove": "/elsewhere/third"})).unwrap(); + // an empty list has nothing to remove + let items = open_recent(&app); + assert!(!items.iter().any(|n| matches!(n, MenuNode::Submenu { .. })), "{items:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `atLaunch` and `recentLibrariesOpened` round-trip, and older or odd files still load. +#[test] +fn the_setting_and_the_open_times_are_kept_in_the_settings_file() { + // files from earlier versions: no key, the default; a value from a later version: the default + let old: crate::UiState = serde_json::from_str(r#"{"settings": {"recentLibraries": ["/a"], "startupView": "detail"}}"#).unwrap(); + assert_eq!(old.settings.at_launch, LaunchLibrary::Last); + assert!(old.settings.recent_opened.is_empty()); + assert_eq!(old.settings.recent_libraries, ["/a"]); + let odd: crate::UiState = + serde_json::from_str(r#"{"settings": {"atLaunch": "someday", "recentLibrariesOpened": 7, "startupView": "detail"}}"#).unwrap(); + assert_eq!(odd.settings.at_launch, LaunchLibrary::Last); + assert!(odd.settings.recent_opened.is_empty()); + assert_eq!(odd.settings.startup_view, crate::state::StartupView::Detail, "the rest of the file still loads"); + + let mut ui = crate::UiState::default(); + ui.settings.at_launch = LaunchLibrary::Ask; + ui.settings.recent_libraries = vec!["/a".into()]; + ui.settings.recent_opened.insert("/a".into(), "2026-10-23T09:00:00".into()); + let saved = serde_json::to_value(&ui).unwrap(); + assert_eq!(saved["settings"]["atLaunch"], "ask"); + assert_eq!(saved["settings"]["recentLibraries"], json!(["/a"]), "the list keeps its old shape"); + assert_eq!(saved["settings"]["recentLibrariesOpened"], json!({"/a": "2026-10-23T09:00:00"})); + let back: crate::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.settings, ui.settings); +} + +/// The Settings choice and `ui.set` change the setting; an opened library gets its time. +#[test] +fn settings_choose_ask_me_and_opening_a_library_records_when() { + let dir = temp("settings"); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + app.services.pick_folder = Some(Box::new(|| None)); + app.ui.settings.recent_opened.insert("/forgotten".into(), "2020-01-01T00:00:00".into()); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + let first = dir.join("first").to_string_lossy().into_owned(); + // the session clock's time (tests: a fixed one), only for listed libraries + assert_eq!(h.app.ui.settings.recent_opened.get(&first).map(String::as_str), Some("2026-09-30T12:00:00")); + assert!(!h.app.ui.settings.recent_opened.contains_key("/forgotten")); + + h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), T); + settle(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:settingsAtLaunch-1"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Ask); + h.request("ui.clickWidget", json!({"id": "button:settingsAtLaunch-0"}), T); + settle(&mut h); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Last); + // also through ui.set, as scripts change settings + let r = h.request("ui.set", json!({"settings": {"atLaunch": "ask"}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Ask); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The app as the host starts it with *Open the last library*: `first` open, Alt watched. +fn opened_last(dir: &Path) -> (Headless, String, String) { + let (first, second) = (make_library(&dir.join("first")), make_library(&dir.join("second"))); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&first, false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + app.ui.settings.recent_libraries = vec![first.clone(), second.clone()]; + app.ui.settings.library_path = first.clone(); + app.alt_asks_for_library = true; + (Headless::new(app, [1300.0, 900.0], 1.0), first, second) +} + +/// Alt held as the window appears shows the list once over the open library; Esc keeps it. +#[test] +fn alt_held_as_the_window_appears_asks_once_over_the_last_library() { + let dir = temp("alt"); + let (mut h, first, _) = opened_last(&dir); + settle(&mut h); + assert!(h.app.library_problem.is_none(), "no Alt: the last library, no window"); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).expect("the list shows"); + assert_eq!(c.selected, Some(0)); + assert!(c.over.is_some(), "over the library that opened"); + assert!(c.libraries[0].unavailable.is_none(), "the open library itself can be chosen"); + assert!(has(&h, "button:libraryOpen")); + // Esc keeps the library that opened + h.request("ui.key", json!({"key": "Escape"}), T); + settle(&mut h); + assert!(!h.quit_requested(), "Esc doesn't quit while a library is open"); + assert!(h.app.library_problem.is_none()); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(first.clone())); + // once only + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + assert!(h.app.library_problem.is_none()); + drop(h); + + // choosing another library from it switches to that one + let dir2 = temp("alt-switch"); + let (mut h, _, second2) = opened_last(&dir2); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + h.request("ui.key", json!({"key": "ArrowDown"}), T); + settle(&mut h); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(second2)); + assert!(h.app.library_problem.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); +} + +/// The Alt watch ends after the first seconds and needs the host to arm it. +#[test] +fn alt_is_only_watched_while_the_window_first_appears_and_when_armed() { + let frame = |app: &mut LightcraftApp, ctx: &egui::Context, time: f64, alt: bool| { + let modifiers = if alt { egui::Modifiers::ALT } else { egui::Modifiers::NONE }; + let raw = egui::RawInput { time: Some(time), events: vec![egui::Event::ModifiersChanged(modifiers)], ..Default::default() }; + ctx.run_ui(raw, |ui| crate::panels::library_problem::logic(app, ui.ctx())).textures_delta.clear(); + }; + let new_app = |armed| { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + app.alt_asks_for_library = armed; + app + }; + // too late: the watch ends, a later Alt does nothing + let (mut app, ctx) = (new_app(true), egui::Context::default()); + frame(&mut app, &ctx, ALT_SECONDS + 0.5, false); + assert!(!app.alt_asks_for_library); + frame(&mut app, &ctx, ALT_SECONDS + 0.6, true); + assert!(app.library_problem.is_none()); + // not armed (--library, LIGHTCRAFT_LIBRARY, --memory, the browser) + let (mut app, ctx) = (new_app(false), egui::Context::default()); + frame(&mut app, &ctx, 0.1, true); + assert!(app.library_problem.is_none()); + // armed, in time + let (mut app, ctx) = (new_app(true), egui::Context::default()); + frame(&mut app, &ctx, 0.1, false); + assert!(app.alt_asks_for_library && app.library_problem.is_none()); + frame(&mut app, &ctx, 0.2, true); + assert!(app.library_problem.as_ref().is_some_and(|p| p.chooser.is_some())); + assert!(!app.alt_asks_for_library); +} + +/// A check that waits until the test lets it go (a network share whose server is gone), then +/// answers like the real one for a missing folder. `release` lets every waiting check go. +fn slow_checks() -> ( + crate::panels::library_problem::RecentChecks, + std::sync::Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>, + std::sync::Arc<std::sync::atomic::AtomicUsize>, +) { + let gate = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (g, n) = (gate.clone(), calls.clone()); + let checks = crate::panels::library_problem::RecentChecks::with_check(move |p| { + n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let (lock, cv) = &*g; + let open = lock.lock().unwrap(); + // (bounded: a test that forgets to release still ends) + let _unused = cv.wait_timeout_while(open, Duration::from_secs(10), |open| !*open).unwrap(); + (!Path::new(p).exists()).then_some(crate::panels::library_problem::Unavailable::NotFound) + }); + (checks, gate, calls) +} + +/// Let every check waiting in [`slow_checks`] answer. +fn release(gate: &std::sync::Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>) { + let (lock, cv) = &**gate; + *lock.lock().unwrap() = true; + cv.notify_all(); +} + +/// Review of #2701: the launch list never waits on a folder. Its rows show "Checking…" while a +/// worker looks (the activity stack lists it), the window keeps drawing frames meanwhile, and the +/// answers grey out a missing library and move the selection to one that can be opened. +#[test] +fn a_slow_check_never_holds_the_launch_window() { + let dir = temp("slow"); + let a = make_library(&dir.join("A")); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![gone.clone(), a.clone()]; + app.ui.settings.library_path = gone.clone(); + let (checks, gate, _) = slow_checks(); + app.recent_checks = checks; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t0 = std::time::Instant::now(); + settle(&mut h); + assert!(t0.elapsed() < Duration::from_secs(5), "frames went on while the check waited: {:?}", t0.elapsed()); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries.iter().all(|l| l.checking && l.unavailable.is_none()), "{c:?}"); + assert_eq!(c.selected, Some(0), "the last library opened, until the answers say otherwise"); + assert!(h.painted_text().iter().any(|t| t == "Checking…"), "{:?}", h.painted_text()); + assert!(h.app.session.activity.list().iter().any(|t| t.label == "Checking recent libraries"), "the activity stack lists the check"); + + release(&gate); + let done = + h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking))); + assert!(done, "the answers came back"); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries[0].unavailable.as_deref().is_some_and(|w| w.contains("Not found")), "{c:?}"); + assert_eq!(c.libraries[1].unavailable, None); + assert_eq!(c.selected, Some(1), "the first library that can be opened, once known"); + assert!(h.app.session.activity.list().iter().all(|t| t.label != "Checking recent libraries"), "the row goes with the check"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Answers that arrive after the person moved the highlight leave it where they put it. +#[test] +fn late_answers_keep_the_row_the_person_chose() { + let dir = temp("touched"); + let a = make_library(&dir.join("A")); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![a.clone(), gone.clone()]; + app.ui.settings.library_path = a.clone(); + let (checks, gate, _) = slow_checks(); + app.recent_checks = checks; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + h.request("ui.key", json!({"key": "ArrowDown"}), T); + settle(&mut h); + release(&gate); + h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking))); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert_eq!(c.selected, Some(1), "still the row moved to, though it can't be opened"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Open Recent Library is rebuilt every frame: it never looks at a folder itself. A +/// library is greyed out only once a worker found it missing, and each path is checked once. +#[test] +fn the_recent_menu_takes_its_answers_from_a_worker() { + use crate::menubar::MenuNode; + let dir = temp("menu-worker"); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![gone.clone()]; + let (checks, gate, calls) = slow_checks(); + app.recent_checks = checks; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let enabled = |h: &Headless| match open_recent(&h.app).first() { + Some(MenuNode::Item { enabled, .. }) => *enabled, + other => panic!("{other:?}"), + }; + for _ in 0..30 { + h.step(); + assert!(enabled(&h), "not known yet: kept enabled (opening says why if it can't)"); + } + release(&gate); + assert!(h.step_until(T, |h| !h.app.recent_checks.is_running() && h.app.recent_checks.is_known(&gone)), "answered"); + for _ in 0..30 { + h.step(); + } + assert!(!enabled(&h), "found missing: greyed out"); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1, "checked once, not every frame"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The reasons are put into words on the UI thread, in the interface language (the worker that +/// found them has none of its own). +#[test] +fn the_reasons_are_in_the_interface_language() { + let dir = temp("lang"); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.language = crate::i18n::Locale::Fr; + app.ui.settings.recent_libraries = vec![gone.clone()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + assert!( + h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking))) + ); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + let why = c.libraries[0].unavailable.clone().unwrap_or_default(); + let english = "Not found: moved, renamed or on a disconnected drive"; + crate::i18n::set_language(crate::i18n::Locale::Fr); + assert_eq!(why, crate::i18n::tr(english), "translated on the UI thread"); + assert_ne!(why, english, "the French catalog has this reason"); + crate::i18n::set_language(crate::i18n::Locale::En); + let _ = std::fs::remove_dir_all(&dir); +} + +/// One drive that never answers doesn't hold the others: each path has its own worker, and the +/// row that waits says the drive isn't answering once it takes long. +#[test] +fn one_hung_drive_doesnt_hold_the_other_libraries() { + let dir = temp("hung"); + let a = make_library(&dir.join("A")); + let hung = dir.join("Hung").to_string_lossy().into_owned(); + let gate = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let g = gate.clone(); + let h_path = hung.clone(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(move |p| { + if p == h_path { + let (lock, cv) = &*g; + let open = lock.lock().unwrap(); + let _unused = cv.wait_timeout_while(open, Duration::from_secs(10), |open| !*open).unwrap(); + } + None + }); + app.ui.settings.recent_libraries = vec![hung.clone(), a.clone()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let a_done = h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| !c.libraries[1].checking)); + assert!(a_done, "the reachable library answered while the other one hangs"); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries[0].checking, "the hung one is still checking"); + release(&gate); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A check whose worker dies (a panic) ends as "couldn't be checked" instead of leaving the row, +/// and every later check, waiting forever. +#[test] +fn a_check_that_dies_doesnt_leave_the_list_waiting() { + let dir = temp("dies"); + let a = make_library(&dir.join("A")); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(|_| panic!("a check that dies")); + app.ui.settings.recent_libraries = vec![a.clone()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let done = h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| !c.libraries[0].checking)); + assert!(done, "the row stopped waiting"); + assert!(!h.app.recent_checks.is_running(), "no check is left running"); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries[0].unavailable.as_deref().is_some_and(|w| w.contains("couldn't be checked")), "{c:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A library found missing comes back once it is opened again (a drive plugged back in): the +/// menu no longer greys it out. +#[test] +fn opening_a_library_clears_an_old_missing_answer() { + use crate::menubar::MenuNode; + let dir = temp("back"); + let a = make_library(&dir.join("A")); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![a.clone()]; + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(|_| Some(crate::panels::library_problem::Unavailable::NotFound)); + let ctx = egui::Context::default(); + crate::panels::library_problem::logic(&mut app, &ctx); + assert!(crate::tasks::wait(&mut app, &ctx, T), "checked"); + assert_eq!(app.recent_checks.exists(&a), Some(false), "found missing (the drive was disconnected)"); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + h.app.run("app.openLibrary", json!({"path": a})).unwrap(); + for _ in 0..4 { + h.step(); + } + assert!(matches!(open_recent(&h.app).first(), Some(MenuNode::Item { enabled: true, .. })), "{:?}", open_recent(&h.app)); + assert_eq!(h.app.recent_checks.reason(&a), None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review of #2701: a library that fails to open from the launch window because another program +/// has it is there all the same. The row shows the open's error, but the menu keeps it enabled and +/// `app.recentLibraries` says it exists; an unreadable catalog doesn't say the library is gone +/// either (`exists: null`). +#[test] +fn a_failed_open_or_an_unreadable_catalog_doesnt_mark_a_library_missing() { + use crate::menubar::MenuNode; + let dir = temp("held"); + let held = make_library(&dir.join("Held")); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![held.clone()]; + app.ui.settings.library_path = held.clone(); + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let answered = + |h: &Headless| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking)); + assert!(h.step_until(T, answered)); + assert_eq!(h.app.recent_checks.exists(&held), Some(true)); + // another program has it: Open from the launch window fails + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held), "another LightCraft").unwrap(); + settle(&mut h); + let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T); + assert_eq!(r["ok"], true, "{r} {:?}", h.app.widgets.iter().map(|(w, _)| w.clone()).filter(|w| w.contains("library")).collect::<Vec<_>>()); + settle(&mut h); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries[0].unavailable.is_some(), "the row shows why it didn't open: {c:?}"); + assert!(h.step_until(T, |h| !h.app.recent_checks.is_running() && h.app.recent_checks.is_known(&held)), "checked again"); + assert_eq!(h.app.recent_checks.exists(&held), Some(true), "a failed open doesn't mark it missing"); + let list = h.app.run("app.recentLibraries", json!({})).unwrap(); + assert_eq!(list[0]["exists"], true, "{list}"); + assert!(matches!(open_recent(&h.app).first(), Some(MenuNode::Item { enabled: true, .. })), "{:?}", open_recent(&h.app)); + drop(lock); + + // an unreadable catalog: not known to be gone + h.app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(|_| { + Some(crate::panels::library_problem::Unavailable::CatalogUnreadable("denied".into())) + }); + assert!(h.step_until(T, |h| h.app.recent_checks.is_known(&held) && !h.app.recent_checks.is_running())); + assert_eq!(h.app.run("app.recentLibraries", json!({})).unwrap()[0]["exists"], serde_json::Value::Null); + assert!(matches!(open_recent(&h.app).first(), Some(MenuNode::Item { enabled: true, .. }))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Photos dropped on the launch list wait for the library chosen (CodeRabbit on #2689): before, +/// they went into the session the library then replaced, and were gone from view. +#[test] +fn photos_dropped_on_the_launch_list_go_into_the_library_chosen() { + let dir = temp("drop"); + let a = make_library(&dir.join("Alpha")); + let mut h = asking(vec![a.clone()], &a, None); + 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(); + // frames with nothing dropped (the drop code runs every frame) hold nothing and say nothing + settle(&mut h); + assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); + let ctx = h.view.ctx.clone(); + assert_eq!(crate::panels::library_problem::hold_dropped(&mut h.app, &ctx, Vec::new()), Some(Vec::new())); + assert!(h.app.ui.toast.is_none(), "an empty drop says nothing: {:?}", h.app.ui.toast); + let photos = vec![photo.to_string_lossy().into_owned()]; + assert!(crate::panels::library_problem::holds_drops(&h.app)); + assert_eq!(crate::panels::library_problem::hold_dropped(&mut h.app, &ctx, photos.clone()), None, "held while no library is open"); + assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.clone()), Some(photos.clone())); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("once a library is open")), "{:?}", h.app.ui.toast); + assert_eq!(h.app.session.catalog.len(), 0, "nothing goes into the session the library replaces"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T)["ok"], true); + settle(&mut h); + h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1); + assert_eq!(h.app.session.catalog.len(), 1, "the dropped photo is imported into the library opened"); + // with a library open, a drop is the usual import + assert!(!crate::panels::library_problem::holds_drops(&h.app)); + assert_eq!(crate::panels::library_problem::hold_dropped(&mut h.app, &ctx, photos.clone()), Some(photos)); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A lookup that fails without saying the library is gone (here a parent folder it may not read; +/// a network share that timed out is the same) is "couldn't check", not "not found": the entry +/// isn't greyed out in File ▸ Open Recent Library (CodeRabbit on #2689). +#[cfg(unix)] +#[test] +fn a_failed_lookup_doesnt_mark_a_recent_library_missing() { + use std::os::unix::fs::PermissionsExt; + /// Gives the folder its permissions back and removes the test's folder, also when an assertion + /// fails (a folder left at mode 000 can't be removed). + struct Cleanup(std::path::PathBuf, std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::set_permissions(&self.1, std::fs::Permissions::from_mode(0o755)); + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = temp("lookup"); + let locked = dir.join("Locked"); + let _cleanup = Cleanup(dir.clone(), locked.clone()); + let inside = make_library(&locked.join("Inside")); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read_dir(&locked).is_ok() { + // running as root (some CI containers): the mode bits don't stop it, so no lookup fails + lightcraft_testkit::skip!(System, "a folder with no permissions can still be read (running as root)"); + return; + } + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![inside.clone(), gone.clone()]; + let mut h = Headless::new(app, [900.0, 600.0], 1.0); + let answered = h.step_until(T, |h| h.app.recent_checks.is_known(&inside) && h.app.recent_checks.is_known(&gone)); + assert!(answered); + assert_eq!(h.app.recent_checks.exists(&gone), Some(false), "a library that isn't there is missing"); + assert_eq!(h.app.recent_checks.exists(&inside), None, "one that couldn't be looked at isn't"); + let why = h.app.recent_checks.reason(&inside).map(|w| w.text()).unwrap_or_default(); + assert!(why.starts_with("This library couldn't be checked:"), "{why}"); + // nor is a folder that is there but refuses to be listed (permissions, macOS privacy) + use crate::panels::library_problem::Unavailable; + assert!(!Unavailable::Unreadable("denied".into()).is_missing() && !Unavailable::LookupFailed("timed out".into()).is_missing()); + assert!(Unavailable::NotFound.is_missing()); + drop(h); +} + +/// A library found is asked about again when the window comes back to the front, not on a timer: a +/// timer would wake every external drive and network share in the list over and over. One not +/// found is looked for again on its own (a drive plugged back in with the app in front). +#[test] +fn recent_libraries_are_checked_again_only_when_the_window_comes_back() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let dir = temp("recheck"); + let a = make_library(&dir.join("A")); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let (calls, gone_calls) = (std::sync::Arc::new(AtomicUsize::new(0)), std::sync::Arc::new(AtomicUsize::new(0))); + let (c, g, gone_path) = (calls.clone(), gone_calls.clone(), gone.clone()); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(move |p| { + if p == gone_path { + g.fetch_add(1, Ordering::SeqCst); + return Some(crate::panels::library_problem::Unavailable::NotFound); + } + c.fetch_add(1, Ordering::SeqCst); + None + }) + .recheck_after(Duration::ZERO); + app.ui.settings.recent_libraries = vec![a.clone(), gone.clone()]; + let mut h = Headless::new(app, [900.0, 600.0], 1.0); + assert!(h.step_until(T, |h| h.app.recent_checks.exists(&a) == Some(true))); + assert!(h.step_until(T, |_| gone_calls.load(Ordering::SeqCst) >= 3), "the one not found is looked for again on its own"); + // focused all along, every answer "old": nothing is asked again + for _ in 0..30 { + h.step(); + } + assert_eq!(calls.load(Ordering::SeqCst), 1, "no check on a timer"); + // the window went to the back and comes to the front again + h.app.recent_checks.focused = false; + assert!(h.step_until(T, |_| calls.load(Ordering::SeqCst) == 2), "asked again when the window comes back"); + for _ in 0..10 { + h.step(); + } + assert_eq!(calls.load(Ordering::SeqCst), 2, "once"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A library the launch list showed as not found comes back to it once found again (a drive plugged +/// back in while the list is up): the row waits for the new check instead of keeping its old answer +/// (FredApps on #2689, #2774). +#[test] +fn a_library_found_again_comes_back_to_the_launch_list() { + found_again(false); +} + +/// The same when every frame is laid out twice (`request_discard`): an answer applied in a frame is +/// visible through all of its passes, and scheduling does not ask about it again in that frame. +#[test] +fn a_library_found_again_comes_back_to_the_launch_list_across_a_multipass_frame() { + found_again(true); +} + +fn found_again(multipass: bool) { + use std::sync::atomic::{AtomicBool, Ordering}; + let dir = temp(if multipass { "found-again-multipass" } else { "found-again" }); + let a = make_library(&dir.join("A")); + let plugged = std::sync::Arc::new(AtomicBool::new(false)); + let p = plugged.clone(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(move |_| { + (!p.load(Ordering::SeqCst)).then_some(crate::panels::library_problem::Unavailable::NotFound) + }) + .recheck_after(Duration::ZERO); + app.ui.settings.recent_libraries = vec![a.clone()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + if multipass { + // The existing context hook egui calls at the start of every pass: ask for the second layout + // pass on every pass 0 (which egui otherwise does only for e.g. a Grid sizing change). + h.view.ctx.on_begin_pass( + "test: force a second layout pass", + std::sync::Arc::new(|ui: &mut egui::Ui| { + if ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("test: force a second layout pass"); + } + }), + ); + let passes = h.view.ctx.cumulative_pass_nr(); + h.step(); + assert_eq!(h.view.ctx.cumulative_pass_nr() - passes, 2, "the hook forced the second pass"); + } + let row = |h: &Headless| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).map(|c| c.libraries[0].clone()).unwrap(); + assert!( + h.step_until(T, |h| { + let r = row(h); + !r.checking && r.unavailable.is_some() + }), + "not found first" + ); + plugged.store(true, Ordering::SeqCst); + assert!( + h.step_until(T, |h| { + let r = row(h); + !r.checking && r.unavailable.is_none() + }), + "found again: it can be opened, {:?}", + row(&h) + ); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/tests_lightroom_destination.rs b/crates/ui-egui/src/tests_lightroom_destination.rs new file mode 100644 index 000000000..b2def730b --- /dev/null +++ b/crates/ui-egui/src/tests_lightroom_destination.rs @@ -0,0 +1,426 @@ +//! Issue #2672: before File ▸ Import Lightroom Catalog… starts, a window asks whether the catalog +//! merges into the open library or goes into a new library (named after the catalog, next to the +//! open one), unless Settings (or the window's "Don't ask again") says to always merge. +//! +//! The catalogs here are fake (`.lrcat` files that aren't SQLite): what is checked is where the +//! import is started, which library is open when it starts, and that the read then fails cleanly. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::headless::Headless; +use crate::state::{Dialog, LightroomImport}; +use crate::{LightcraftApp, Services, UiState}; + +const T: Duration = Duration::from_secs(20); + +/// A folder of its own per test: `Library` (a LightCraft library, demo photos in it unless +/// `empty`) and `Lightroom/Travel.lrcat` (a fake catalog) side by side. +struct Fixture { + /// The test's own folder. + root: PathBuf, + /// `root/Library`. + lib: PathBuf, + /// `root/Lightroom/Travel.lrcat`. + lrcat: PathBuf, +} + +impl Fixture { + /// Make the folders and the fake catalog (an earlier run's leftovers removed). + fn new(tag: &str) -> Fixture { + let root = std::env::temp_dir().join(format!("lc-ui-lrdest-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let lr = root.join("Lightroom"); + std::fs::create_dir_all(&lr).unwrap(); + let lrcat = lr.join("Travel.lrcat"); + std::fs::write(&lrcat, b"not really SQLite").unwrap(); + Fixture { lib: root.join("Library"), root, lrcat } + } + + /// The desktop app on `Library`, with a folder dialog (for Choose…) and screenshots. + fn app(&self, empty: bool) -> Headless { + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&self.lib, !empty).unwrap(); + let png = |img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + }; + // (a folder dialog for the window's Choose…, and what a screenshot needs) + let services = Services { + pick_folder: Some(Box::new(|| None)), + 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() + }; + let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0); + h.step(); + h + } + + /// The catalog's path as text. + fn lrcat(&self) -> String { + self.lrcat.to_string_lossy().into_owned() + } +} + +impl Drop for Fixture { + /// Remove the folders. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// The widget `id` was drawn last frame. +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +/// Click the widget `id` through the control channel, then draw a frame. +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); +} + +/// The open library's folder. +fn open_dir(h: &Headless) -> Option<PathBuf> { + h.app.session.library.as_ref().map(|l| l.dir.clone()) +} + +/// The window's state: (new library chosen, name, location, don't ask). +fn destination(h: &Headless) -> Option<(bool, String, String, bool)> { + match &h.app.ui.dialog { + Some(Dialog::LightroomDestination { new_library, name, location, dont_ask, .. }) => { + Some((*new_library, name.clone(), location.clone(), *dont_ask)) + } + _ => None, + } +} + +/// Let the import of the fake catalog end (it fails to read) and return what it reported. +fn import_ends(h: &mut Headless, lrcat: &Path) -> Value { + assert!(h.step_until(T, |h| h.app.lightroom.is_none()), "the import never ended"); + let last = h.app.lightroom_last.clone().unwrap_or_default(); + assert_eq!(last["path"], json!(lrcat), "the import was of this catalog: {last}"); + last +} + +/// A headless screenshot into `$LIGHTCRAFT_TEST_SHOTS`, when it is set. +fn shot(h: &mut Headless, name: &str) { + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": Path::new(&p).join(name).to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } +} + +/// The window appears before anything starts, with its choices; Merge imports into the open library. +#[test] +fn the_window_asks_and_merge_imports_into_the_open_library() { + let f = Fixture::new("merge"); + let mut h = f.app(false); + let photos = h.app.session.catalog.len(); + assert!(photos > 0); + + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r, json!({"asking": true, "path": f.lrcat(), "into": "new"})); + h.step(); + h.step(); + // a library with photos: the window opens on a new library, named after the catalog, next to the open one + assert_eq!(destination(&h), Some((true, "Travel".into(), f.root.to_string_lossy().into_owned(), false))); + for id in [ + "radio:lightroomDestination-open", + "radio:lightroomDestination-new", + "field:lightroomLibraryName", + "field:lightroomLibraryLocation", + "button:lightroomLibraryChoose", + "check:lightroomDontAsk", + "button:dialogOk", + "button:dialogCancel", + ] { + assert!(has(&h, id), "{id} is missing"); + } + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none(), "nothing starts before the answer"); + shot(&mut h, "lightroom-destination-2672.png"); + + // Merge into the open library + click(&mut h, "radio:lightroomDestination-open"); + assert_eq!(destination(&h).map(|d| d.0), Some(false)); + click(&mut h, "button:dialogOk"); + assert!(destination(&h).is_none()); + assert!(h.app.lightroom.is_some() || h.app.lightroom_last.is_some(), "the import started"); + import_ends(&mut h, &f.lrcat); + assert_eq!(open_dir(&h), Some(f.lib.clone()), "into the library open before"); + assert_eq!(h.app.session.catalog.len(), photos); + assert!(!f.root.join("Travel").exists(), "no library was made"); + assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::Ask, "it asks again next time"); +} + +/// Create a new library: it is made next to the open one, named after the catalog, and opened +/// before the import starts there. +#[test] +fn create_a_new_library_opens_it_before_the_import_starts() { + let f = Fixture::new("new"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + h.step(); + assert_eq!(destination(&h).map(|d| d.0), Some(true)); + // ui.dialog.confirm answers what the window's button does + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + let new = f.root.join("Travel"); + let result = &r["result"]; + assert_eq!(result["library"]["path"], json!(new.to_string_lossy()), "{r}"); + assert_eq!(result["library"]["name"], "Travel", "{r}"); + assert_eq!(result["path"], json!(f.lrcat()), "{r}"); + // the new library is the open one, and it was when the import started (the import's + // snapshot is taken in the same call, after the switch) + assert_eq!(open_dir(&h), Some(new.clone())); + assert!(lightcraft_engine::library::is_library(&new)); + assert_eq!(h.app.ui.settings.library_path, new.to_string_lossy(), "opened at the next launch"); + assert!(h.app.ui.settings.recent_libraries.contains(&f.lib.to_string_lossy().into_owned()), "the old one is a recent library"); + import_ends(&mut h, &f.lrcat); + assert_eq!(open_dir(&h), Some(new)); +} + +/// Cancel: no import, no library made, the open library stays. +#[test] +fn cancel_does_nothing() { + let f = Fixture::new("cancel"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + h.step(); + assert!(destination(&h).is_some()); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + assert_eq!(open_dir(&h), Some(f.lib.clone())); + assert!(!f.root.join("Travel").exists()); +} + +/// An empty library has nothing to merge with: the window opens on Merge. +#[test] +fn an_empty_library_opens_the_window_on_merge() { + let f = Fixture::new("empty"); + let mut h = f.app(true); + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r["into"], "open", "{r}"); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomDestination { new_library: false, photos: 0, .. })), "{:?}", h.app.ui.dialog); + // (the window in French, which translates it: the language is this test thread's own) + if std::env::var_os("LIGHTCRAFT_TEST_SHOTS").is_some() { + h.app.run("app.language.french", json!({})).unwrap(); + h.step(); + h.step(); + shot(&mut h, "lightroom-destination-empty-fr-2672.png"); + h.app.run("app.language.english", json!({})).unwrap(); + } +} + +/// "Always merge into the open library" imports without the window. +#[test] +fn always_merge_skips_the_window() { + let f = Fixture::new("always"); + let mut h = f.app(false); + h.app.ui.settings.lightroom_import = LightroomImport::AlwaysMerge; + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + h.step(); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); + assert_eq!(open_dir(&h), Some(f.lib.clone())); +} + +/// "Don't ask again" (with Merge only) sets "Always merge", which is saved in `ui.json`. +#[test] +fn dont_ask_again_merges_from_then_on_and_is_saved() { + let f = Fixture::new("dontask"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + h.step(); + // greyed out while a new library is chosen: it only speaks of merging + click(&mut h, "check:lightroomDontAsk"); + assert_eq!(destination(&h).map(|d| d.3), Some(false)); + click(&mut h, "radio:lightroomDestination-open"); + click(&mut h, "check:lightroomDontAsk"); + assert_eq!(destination(&h).map(|d| d.3), Some(true)); + click(&mut h, "button:dialogOk"); + assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::AlwaysMerge); + import_ends(&mut h, &f.lrcat); + // saved with the UI state (ui.json) and read back + let saved = serde_json::to_string(&h.app.ui).unwrap(); + assert!(saved.contains(r#""lightroomImport":"alwaysMerge""#), "{saved}"); + let back: UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(back.settings.lightroom_import, LightroomImport::AlwaysMerge); + // the next import doesn't ask + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); +} + +/// Settings written before the setting existed, or hand-edited, ask (the default). +#[test] +fn old_and_odd_settings_files_ask() { + let old: UiState = serde_json::from_str(r#"{"settings": {"startupView": "detail"}}"#).unwrap(); + assert_eq!(old.settings.lightroom_import, LightroomImport::Ask); + let odd: UiState = serde_json::from_str(r#"{"settings": {"lightroomImport": "sometimes", "startupView": "detail"}}"#).unwrap(); + assert_eq!(odd.settings.lightroom_import, LightroomImport::Ask); + assert_eq!(odd.settings.startup_view, crate::state::StartupView::Detail, "the rest of the file still loads"); +} + +/// Settings ▸ General ▸ When importing a Lightroom catalog switches between the two. +#[test] +fn settings_general_switches_the_question_off_and_on() { + let f = Fixture::new("settings"); + let mut h = f.app(false); + h.app.run("app.settings", json!({})).unwrap(); + h.step(); + h.step(); + click(&mut h, "button:settingsLightroomImport-1"); + assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::AlwaysMerge); + shot(&mut h, "settings-lightroom-import-2672.png"); + click(&mut h, "button:settingsLightroomImport-0"); + assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::Ask); +} + +/// A new library's folder must be new or empty (File ▸ New Library…'s rule): the window says so +/// and stays open to pick another name; nothing is imported and the library stays. +#[test] +fn a_folder_that_isnt_empty_is_refused_and_the_window_stays() { + let f = Fixture::new("notempty"); + std::fs::create_dir_all(f.root.join("Travel")).unwrap(); + std::fs::write(f.root.join("Travel").join("notes.txt"), b"mine").unwrap(); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + h.step(); + // the free name is offered… + assert_eq!(destination(&h).map(|d| d.1), Some("Travel 2".to_string())); + // …but the user types the taken one + if let Some(Dialog::LightroomDestination { name, .. }) = h.app.ui.dialog.as_mut() { + *name = "Travel".into(); + } + click(&mut h, "button:dialogOk"); + assert!(destination(&h).is_some(), "the window stays to be corrected"); + let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); + assert!(toast.contains("isn't empty"), "{toast}"); + assert_eq!(open_dir(&h), Some(f.lib.clone())); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + // the same through the control channel + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], false, "{r}"); + assert!(destination(&h).is_some()); +} + +/// `into` acts without asking (agents, scripts); bad params are refused before anything happens. +#[test] +fn into_acts_without_the_window() { + let f = Fixture::new("into"); + let mut h = f.app(false); + for bad in [json!({"path": f.lrcat(), "into": "elsewhere"}), json!({"path": f.lrcat(), "into": "new", "location": "relative/dir"})] { + let err = h.app.run("file.importLightroom", bad.clone()).unwrap_err(); + assert!(err.contains("file.importLightroom"), "{bad}: {err}"); + } + // a catalog that isn't there doesn't leave an empty library behind + let missing = f.root.join("Gone.lrcat"); + let err = h.app.run("file.importLightroom", json!({"path": missing.to_string_lossy(), "into": "new"})).unwrap_err(); + assert!(err.contains("no Lightroom catalog"), "{err}"); + assert!(!f.root.join("Gone").exists()); + assert!(destination(&h).is_none() && h.app.lightroom_last.is_none()); + + let elsewhere = f.root.join("Libraries"); + let r = h + .app + .run("file.importLightroom", json!({"path": f.lrcat(), "into": "new", "name": "Trips", "location": elsewhere.to_string_lossy()})) + .unwrap(); + assert_eq!(r["library"]["name"], "Trips", "{r}"); + assert_eq!(open_dir(&h), Some(elsewhere.join("Trips"))); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); + + // a new library while another import runs: refused before anything is made + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat(), "into": "open"})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + let err = h.app.run("file.importLightroom", json!({"path": f.lrcat(), "into": "new", "name": "Again"})).unwrap_err(); + assert!(err.contains("already running"), "{err}"); + assert!(!elsewhere.join("Again").exists()); + // and the window isn't shown for an answer that couldn't be carried out + assert!(h.app.run("file.importLightroom", json!({"path": f.lrcat()})).is_err()); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); + assert_eq!(open_dir(&h), Some(elsewhere.join("Trips"))); +} + +/// A session in memory (no library on disk) has nothing to put a new library next to: it merges +/// without asking, as before. +#[test] +fn a_library_in_memory_merges_without_asking() { + let f = Fixture::new("memory"); + let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()), [1200.0, 800.0], 1.0); + h.step(); + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); +} + +/// Another library opened while the window was up (an agent, the control channel): the window +/// doesn't merge into it. +#[test] +fn merge_is_refused_when_another_library_was_opened_meanwhile() { + let f = Fixture::new("stale"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + click(&mut h, "radio:lightroomDestination-open"); + let other = f.root.join("Other"); + let dialog = h.app.ui.dialog.take(); + h.app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap(); + let stale = dialog.clone(); + h.app.ui.dialog = dialog; + h.step(); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the window closes: the library it named is gone"); + // confirm's own error, not the last toast: another notice (the library switch's, on macOS) + // can replace the toast before it is read + let stale = stale.unwrap(); + let err = crate::lightroom_destination::confirm(&mut h.app, &stale).unwrap_err(); + assert!(err.contains("nothing was imported"), "{err}"); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + assert_eq!(open_dir(&h), Some(other)); +} + +/// The same for *Create a new library* (CodeRabbit on #2685): a window left over from before a +/// library switch makes no library and imports nothing. +#[test] +fn a_new_library_is_refused_when_another_library_was_opened_meanwhile() { + let f = Fixture::new("stale-new"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + click(&mut h, "radio:lightroomDestination-new"); + let other = f.root.join("Other"); + let dialog = h.app.ui.dialog.take(); + let target = match &dialog { + Some(crate::state::Dialog::LightroomDestination { name, location, .. }) => std::path::Path::new(location).join(name), + _ => panic!("the destination window is up"), + }; + h.app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap(); + let stale = dialog.clone(); + h.app.ui.dialog = dialog; + h.step(); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the window closes: the library it named is gone"); + // confirm's own error, not the last toast: another notice (the library switch's, on macOS) + // can replace the toast before it is read + let stale = stale.unwrap(); + let err = crate::lightroom_destination::confirm(&mut h.app, &stale).unwrap_err(); + assert!(err.contains("nothing was imported"), "{err}"); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + assert_eq!(open_dir(&h), Some(other)); + assert!(!target.exists(), "no library was made at {}", target.display()); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 2ce24355f..c8d6d6c2e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1192,6 +1192,29 @@ fn edit_in_external_editor_opens_the_copy() { h.settle(SETTLE); } +/// An editor that won't open the copy: the copy is in the library already, so the command succeeds +/// with `failed` set and a toast saying why, instead of an error a script would retry (and make +/// another copy each time). +#[test] +fn edit_in_reports_an_editor_that_wont_open_without_failing() { + let dir = std::env::temp_dir().join(format!("lc-ui-ext-fail-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let services = Services { png: None, open_with: Some(Box::new(|_: &str, _: &str| Err("the editor quit".into()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "PhotoCraft".into(); + let n = h.app.session.catalog.len(); + let r = h.request("engine.execute", json!({"command": "photo.editInExternal", "params": {"dir": dir.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["failed"], "the editor quit", "{r}"); + assert_eq!(h.app.session.catalog.len(), n + 1, "the copy stays in the library"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("the editor quit"), "{toast}"); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + /// Edit in PhotoCraft: a PSD copy (what PhotoCraft saves back to), handed to "PhotoCraft" for /// the host to find; when the host can't find it, no copy is made. #[test] @@ -1224,6 +1247,12 @@ fn edit_in_photocraft_opens_a_psd_copy() { let r = h.request("engine.execute", params.clone(), T); assert_eq!(r["ok"], false, "{r}"); assert_eq!((h.app.session.catalog.len(), opened.lock().unwrap().len()), (n, 0), "no copy without PhotoCraft"); + // and it says so, however it was asked for (menu, shortcut, context menu) + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(!toast.is_empty(), "the missing editor is reported"); + h.app.ui.toast = None; + let r = h.app.run("photo.editInPhotoCraft", json!({})); + assert!(r.is_err() && h.app.ui.toast.is_some(), "{r:?} {:?}", h.app.ui.toast); installed.store(true, std::sync::atomic::Ordering::SeqCst); let r = h.request("engine.execute", params, T); assert_eq!(r["ok"], true, "{r}"); diff --git a/crates/ui-egui/src/tests_merge_preview.rs b/crates/ui-egui/src/tests_merge_preview.rs new file mode 100644 index 000000000..09419758d --- /dev/null +++ b/crates/ui-egui/src/tests_merge_preview.rs @@ -0,0 +1,74 @@ +//! Headless tests of the Photo Merge dialog's preview: click to zoom in, drag to pan, click to fit. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(120); + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn press(h: &mut Headless, pos: egui::Pos2, pressed: bool) { + h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); +} + +fn click_at(h: &mut Headless, pos: egui::Pos2) { + h.app.synthetic.push(egui::Event::PointerMoved(pos)); + press(h, pos, true); + press(h, pos, false); + h.settle(T); +} + +#[test] +fn a_click_on_the_merge_preview_zooms_in_and_a_drag_pans() { + let dir = std::env::temp_dir().join(format!("lc-merge-zoom-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut paths = Vec::new(); + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); + h.settle(T); + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + assert!(h.step_until(T, |h| h.app.merge.preview.is_some() && h.app.merge.preview_task.is_none()), "the preview rendered"); + h.settle(T); + assert_eq!(h.app.merge.zoom, None, "the preview opens fitted"); + + let area = rect(&h, "merge.preview"); + // right of and above the middle: a point of the fitted image whatever its aspect + let at = area.center() + egui::vec2(area.width() * 0.2, area.height() * -0.1); + click_at(&mut h, at); + let (u, v) = h.app.merge.zoom.expect("the click zoomed in"); + assert!(u > 0.55 && v < 0.5, "zoomed in on the point clicked ({u}, {v})"); + + // drag left: the image follows the pointer, so a point further right comes to the middle + let from = area.center(); + let to = from - egui::vec2(60.0, 0.0); + h.app.synthetic.push(egui::Event::PointerMoved(from)); + press(&mut h, from, true); + h.step(); + for i in 1..=6 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + press(&mut h, to, false); + h.settle(T); + let (u2, v2) = h.app.merge.zoom.expect("still zoomed in after a drag"); + assert!(u2 > u + 0.01 && (v2 - v).abs() < 1e-3, "the drag panned right ({u}, {v}) → ({u2}, {v2})"); + + click_at(&mut h, area.center()); + assert_eq!(h.app.merge.zoom, None, "a second click fits the preview again"); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/tests_open_lightroom.rs b/crates/ui-egui/src/tests_open_lightroom.rs index 8f810fa9f..d97f9d0e8 100644 --- a/crates/ui-egui/src/tests_open_lightroom.rs +++ b/crates/ui-egui/src/tests_open_lightroom.rs @@ -1,6 +1,6 @@ //! Issue #891: File ▸ Open Library… given a Lightroom Classic catalog folder doesn't make a //! LightCraft library inside it. The library open now stays, and a dialog offers Import Lightroom -//! Catalog… with the catalog found, which starts the import of that `.lrcat`. +//! Catalog… with the catalog found, which asks where that `.lrcat` goes (#2672), then imports it. use std::time::Duration; @@ -85,12 +85,23 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}"); h.step(); h.step(); - // Import Lightroom Catalog…: the import of that catalog starts (no file dialog), into the - // library open now. (This fake catalog then fails to read: only its path matters here.) + // Import Lightroom Catalog…: no file dialog, the window that asks where that catalog goes + // (#2672) takes its place, as File ▸ Import Lightroom Catalog… does + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); + h.step(); + assert!( + matches!(&h.app.ui.dialog, Some(Dialog::LightroomDestination { catalog, .. }) if *catalog == lrcat.to_string_lossy()), + "{:?}", + h.app.ui.dialog + ); + // Merge: the import of that catalog starts, into the library open now. (This fake catalog + // then fails to read: only its path matters here.) + assert_eq!(h.request("ui.clickWidget", json!({"id": "radio:lightroomDestination-open"}), T)["ok"], true); + h.step(); assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); h.step(); // A fast read failure can already have replaced the offer with its lasting result. - assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. }))); + assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. } | Dialog::LightroomDestination { .. }))); assert!(h.step_until(T, |h| h.app.lightroom.is_none())); assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomResult { summary, first: None, .. }) if summary.contains("failed"))); let last = h.app.lightroom_last.clone().unwrap_or_default(); diff --git a/crates/ui-egui/src/tests_preset_favorites.rs b/crates/ui-egui/src/tests_preset_favorites.rs new file mode 100644 index 000000000..0dc9900ea --- /dev/null +++ b/crates/ui-egui/src/tests_preset_favorites.rs @@ -0,0 +1,81 @@ +//! Issue #1999: starred presets are listed in a Favorites section at the top of the Presets panel, +//! as starred profiles are in the Profile browser, and stay in their own group too. + +use crate::{LightcraftApp, Services, headless::Headless}; +use serde_json::json; + +/// A headless app on the demo library with the Presets panel open and laid out. +fn app() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.app.run("panel.presets", json!({})).unwrap(); + for _ in 0..5 { + h.step(); + } + h +} + +/// Where the widget registered as `id` was drawn in the last frame, if it was. +fn rect(h: &Headless, id: &str) -> Option<egui::Rect> { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +/// The topmost preset group header on screen (the Favorites header is `presetFavorites`). +fn first_group(h: &Headless) -> String { + let groups = h.app.widgets.iter().filter(|(w, _)| w.starts_with("presetGroup:") || w == "presetFavorites"); + groups.min_by(|a, b| a.1.top().total_cmp(&b.1.top())).map(|(w, _)| w.clone()).unwrap() +} + +/// Starring two presets makes a Favorites section the first header, listing them in list order +/// right under it, while each stays in its own group too. +#[test] +fn starred_presets_are_listed_in_favorites_on_top() { + let mut h = app(); + assert!(rect(&h, "presetFavorites").is_none(), "no section while nothing is starred"); + let first = h.app.session.presets.first().map(|p| p.id.clone()).unwrap(); + let last = h.app.session.presets.last().map(|p| p.id.clone()).unwrap(); + h.app.run("preset.favorite", json!({"id": last})).unwrap(); + h.app.run("preset.favorite", json!({"id": first})).unwrap(); + for _ in 0..3 { + h.step(); + } + assert_eq!(first_group(&h), "presetFavorites"); + let header = rect(&h, "presetFavorites").unwrap(); + // in list order, right under the header + let a = rect(&h, &format!("presetFavorite:{first}")).unwrap(); + let b = rect(&h, &format!("presetFavorite:{last}")).unwrap(); + assert!(header.bottom() <= a.top() && a.top() < b.top(), "{header:?} {a:?} {b:?}"); + // still in its own group + assert!(rect(&h, &format!("preset:{first}")).is_some()); + // unstarring the last one removes the section + for id in [&first, &last] { + h.app.run("preset.favorite", json!({"id": id})).unwrap(); + } + for _ in 0..3 { + h.step(); + } + assert!(rect(&h, "presetFavorites").is_none()); +} + +/// A user group named "Favorites" or "favorites" keeps its own header, apart from the section's: a +/// click aimed at either id lands on the right row (the widget lookup takes the last match). +#[test] +fn user_groups_named_favorites_keep_their_own_header() { + let mut h = app(); + let id = h.app.session.presets.first().map(|p| p.id.clone()).unwrap(); + h.app.run("preset.favorite", json!({"id": id})).unwrap(); + for group in ["Favorites", "favorites"] { + h.app.run("preset.create", json!({"name": format!("Mine in {group}"), "group": group})).unwrap(); + } + for _ in 0..3 { + h.step(); + } + let section = rect(&h, "presetFavorites").unwrap(); + for group in ["Favorites", "favorites"] { + let id = format!("presetGroup:{group}"); + let user = rect(&h, &id).unwrap_or_else(|| panic!("the user's {group:?} group is listed")); + assert_ne!(user, section, "{id} is its own row"); + assert_eq!(h.app.widgets.iter().filter(|(w, _)| *w == id).count(), 1, "{id} names one row"); + } + assert_eq!(h.app.widgets.iter().filter(|(w, _)| w == "presetFavorites").count(), 1); +} diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 070921256..e87e52355 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -284,6 +284,25 @@ mod in_the_loupe { assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); } + // Given a diagnostic overlay (Visualize Spots), when I zoom to 1:1, then the visible part is a + // window render carrying the overlay, over a canvas-sized whole frame, as without it + #[test] + fn visualize_spots_at_one_to_one_is_a_window() { + let (mut h, native) = detail(); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let plain = h.app.region_view.expect("a window render without the overlay"); + let r = h.request("ui.menu.invoke", json!({"id": "view.visualizeSpots"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.visualize_spots); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let spots = h.app.region_view.expect("a window render with the overlay"); + assert_eq!(spots.full.0.max(spots.full.1), native, "cut from the photo's own pixels"); + assert_ne!(spots.settings, plain.settings, "the overlay is part of the window's look"); + } + // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that #[test] fn an_explicit_limit_still_caps_one_to_one() { @@ -530,13 +549,16 @@ mod in_the_loupe { assert!(main <= small, "the draft is {main} px, a small one at most {small}"); } - // Given soft proofing (rendered on the CPU even with a GPU), a drag drafts small (#330) + // Given soft proofing against an output-device profile (rendered on the CPU even with a GPU), a + // drag drafts small (#330); proofs into an output space run on the GPU like any render #[test] fn a_soft_proofed_drag_drafts_small() { // the GPU device up front (the loupe never waits for it): with one, this is the case where // the GPU is there and the render still goes to the CPU let _ = lightcraft_engine::gpu::available(); let (mut h, _) = detail(); + let lut = lightcraft_pipeline::ProofLut::new(2, vec![[0.5, 0.5, 0.5, 0.0]; 8]).expect("a valid table"); + h.app.ui.proof.profile = lightcraft_pipeline::register_proof_profile(0x5eed_9f01, lut); h.app.ui.soft_proof = true; h.settle(SETTLE); let (main, small) = drag_exposure(&mut h); diff --git a/crates/ui-egui/src/tests_progress.rs b/crates/ui-egui/src/tests_progress.rs new file mode 100644 index 000000000..09e9234a0 --- /dev/null +++ b/crates/ui-egui/src/tests_progress.rs @@ -0,0 +1,284 @@ +//! Progress bars, spinners and busy buttons: the fraction is always a sane number, a bar is +//! announced as progress with its percentage and reachable by its id, a busy button is announced +//! busy, ignores clicks and keeps its width, and a thumbnail's placeholder stands apart from what +//! it sits on in every theme. + +use egui::{Pos2, Rect, pos2, vec2}; + +use crate::button::{Button, contrast}; +use crate::icons::Icon; +use crate::progress::{Progress, clamp_fraction, fraction_of, spinner}; +use crate::theme::{ThemeKind, Tokens}; + +/// What a screen reader gets for one node. +#[derive(Debug, Clone, PartialEq)] +struct Node { + role: String, + label: String, + busy: bool, + value: Option<f64>, +} + +/// Run three frames of `draw` (pointer `events` in the second); return the nodes of the last +/// frame with an accessibility tree, the automation ids registered in the last frame, and what +/// `draw` returned. +fn frames<T: Default>(events: Vec<egui::Event>, mut draw: impl FnMut(&mut egui::Ui) -> T) -> (Vec<Node>, Vec<(String, Rect)>, T) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let (mut nodes, mut ids, mut last) = (Vec::new(), Vec::new(), T::default()); + for frame in 0..3 { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events: if frame == 1 { events.clone() } else { Vec::new() }, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| last = draw(ui)); + out.textures_delta.clear(); + ids = crate::widgets::take_registry(&ctx); + if let Some(update) = out.platform_output.accesskit_update.take() { + nodes = update + .nodes + .iter() + .map(|(_, n)| Node { + role: format!("{:?}", n.role()), + label: n.label().unwrap_or_default().to_string(), + busy: n.is_busy(), + value: n.numeric_value(), + }) + .collect(); + } + } + (nodes, ids, last) +} + +#[test] +fn the_fraction_is_always_between_nothing_and_all() { + assert_eq!(clamp_fraction(f32::NAN), 0.0); + assert_eq!(clamp_fraction(-3.0), 0.0); + assert_eq!(clamp_fraction(7.5), 1.0); + assert_eq!(clamp_fraction(f32::INFINITY), 1.0); + assert_eq!(clamp_fraction(f32::NEG_INFINITY), 0.0); + assert_eq!(clamp_fraction(0.25), 0.25); + assert_eq!(Progress::new(Some(f32::NAN)).fraction(), Some(0.0)); + assert_eq!(Progress::new(Some(2.0)).fraction(), Some(1.0)); + assert_eq!(Progress::new(None).fraction(), None); +} + +#[test] +fn an_unknown_total_is_an_indeterminate_bar_not_an_empty_one() { + assert_eq!(fraction_of(0, 0), None); + assert_eq!(fraction_of(5, 0), None); + assert_eq!(fraction_of(0, 10), Some(0.0)); + assert_eq!(fraction_of(5, 10), Some(0.5)); + assert_eq!(fraction_of(20, 10), Some(1.0)); +} + +#[test] +fn a_bar_is_progress_with_a_value_and_reachable_by_its_id() { + let (nodes, ids, _) = frames(Vec::new(), |ui| { + Progress::new(Some(0.42)).label("Making the denoised picture… 42%").widget("denoise:progress").show(ui); + }); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("no progress node: {nodes:?}")); + assert_eq!(bar.label, "Making the denoised picture… 42%"); + assert_eq!(bar.value, Some(42.0)); + assert!(ids.iter().any(|(id, r)| id == "denoise:progress" && r.width() > 0.0), "{ids:?}"); +} + +#[test] +fn an_indeterminate_bar_says_progress_without_a_value_and_is_drawn() { + let (nodes, ids, width) = frames(Vec::new(), |ui| Progress::new(None).label("Starting…").widget("p:x").show(ui).rect.width()); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}")); + assert_eq!(bar.value, None); + assert!(ids.iter().any(|(id, _)| id == "p:x")); + assert!(width >= 96.0, "fills the width the layout has"); +} + +#[test] +fn a_bar_fills_the_layout_unless_given_a_width() { + let (_, _, (fill, fixed, tiny_room)) = frames(Vec::new(), |ui| { + let fill = Progress::new(Some(0.5)).show(ui).rect.width(); + let fixed = Progress::new(Some(0.5)).width(150.0).show(ui).rect.width(); + let tiny_room = ui.scope(|ui| { + ui.set_max_width(40.0); + Progress::new(Some(0.5)).show(ui).rect.width() + }); + (fill, fixed, tiny_room.inner) + }); + assert!(fill > 300.0, "fills: {fill}"); + assert_eq!(fixed, 150.0); + assert!(tiny_room >= 96.0, "never a sliver: {tiny_room}"); +} + +#[test] +fn a_spinner_takes_the_room_it_is_given() { + let (_, _, size) = frames(Vec::new(), |ui| spinner(ui, 14.0).rect.size()); + assert_eq!(size, vec2(14.0, 14.0)); +} + +fn click_at(at: Pos2) -> Vec<egui::Event> { + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ] +} + +#[test] +fn a_busy_button_ignores_clicks_and_is_announced_busy() { + let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)); + let click = |busy: bool| { + let mut clicked = false; + let (nodes, _, ()) = frames(click_at(pos2(40.0, 15.0)), |ui| clicked |= Button::new("Analyze").busy(busy).show_at(ui, place).clicked()); + (nodes, clicked) + }; + let (nodes, clicked) = click(true); + assert!(!clicked, "a busy button does nothing"); + let node = nodes.iter().find(|n| n.role == "Button" && n.label == "Analyze").unwrap_or_else(|| panic!("{nodes:?}")); + assert!(node.busy, "announced busy"); + + let (nodes, clicked) = click(false); + assert!(clicked, "the same button, not busy, is clicked"); + assert!(!nodes.iter().any(|n| n.busy), "{nodes:?}"); +} + +#[test] +fn a_busy_button_stays_reachable_by_the_keyboard() { + let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)); + let mut asked = false; + let (_, _, focused) = frames(Vec::new(), |ui| { + let r = Button::new("Analyze").busy(true).show_at(ui, place); + if !std::mem::replace(&mut asked, true) { + r.request_focus(); + } + r.has_focus() + }); + assert!(focused, "focus is kept while it works"); +} + +#[test] +fn a_button_keeps_its_width_while_busy() { + for (text, icon) in [("Analyze", None), ("Analyze", Some(Icon::Trash)), ("", Some(Icon::Trash))] { + for small in [false, true] { + let (_, _, (idle, busy)) = frames(Vec::new(), |ui| { + let mut make = |b: bool| { + let mut btn = if text.is_empty() { Button::icon_only(Icon::Trash, "Remove") } else { Button::new(text) }; + if let Some(i) = icon { + btn = btn.icon(i); + } + if small { + btn = btn.small(); + } + btn.busy(b).show(ui).rect.size() + }; + (make(false), make(true)) + }); + assert_eq!(idle, busy, "{text:?} {icon:?} small={small}"); + } + } +} + +#[test] +fn the_thumbnail_placeholder_stands_apart_in_every_theme() { + for kind in ThemeKind::ALL { + let t = Tokens::for_kind(kind); + for (name, behind) in [("grid", t.grid_bg), ("cell", t.cell), ("selected cell", t.cell_selected), ("canvas", t.canvas)] { + let c = contrast(t.skeleton, behind); + // the light themes' surfaces are close together, so the placeholder has to be set + // further off them to read at all + let floor = if matches!(kind, ThemeKind::Silver | ThemeKind::Paper) { 1.15 } else { 1.1 }; + assert!(c >= floor, "{kind:?}: the placeholder on the {name} is {c:.2}:1"); + } + let mark = contrast(t.text, t.skeleton); + assert!(mark >= 3.0, "{kind:?}: the failure mark on the placeholder is {mark:.2}:1"); + } +} + +#[test] +fn a_failed_filmstrip_thumbnail_is_announced_with_its_reason() { + let spoken = |failed: bool| { + let (nodes, _, _) = frames(Vec::new(), |ui| { + let resp = ui.interact(Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 80.0)), egui::Id::unique("film-test"), egui::Sense::click()); + crate::widgets::announce(&resp, crate::panels::detail::film_spoken("IMG_0001.jpg", failed)); + }); + nodes.into_iter().find(|n| n.role == "Button").map(|n| n.label) + }; + assert_eq!(spoken(false).as_deref(), Some("IMG_0001.jpg")); + assert_eq!(spoken(true).as_deref(), Some("IMG_0001.jpg, This photo can't be opened")); +} + +#[test] +fn a_failed_filmstrip_thumbnail_without_a_file_name_has_no_leading_comma() { + assert_eq!(crate::panels::detail::film_spoken("", true), "This photo can't be opened"); + assert_eq!(crate::panels::detail::film_spoken("", false), ""); +} + +#[test] +fn the_sam_bar_keeps_its_name_and_height_while_the_size_is_unknown() { + let (nodes, _, (known, unknown)) = frames(Vec::new(), |ui| { + let known = crate::panels::masking::sam_download_bar(ui, 5, 10, 200.0).rect.height(); + let unknown = crate::panels::masking::sam_download_bar(ui, 0, 0, 200.0).rect.height(); + (known, unknown) + }); + assert_eq!(known, unknown); + let bars: Vec<_> = nodes.iter().filter(|n| n.role == "ProgressIndicator").collect(); + assert_eq!(bars.len(), 2, "{nodes:?}"); + assert!(bars.iter().all(|b| b.label.starts_with("SAM 3")), "{bars:?}"); +} + +#[test] +fn the_denoise_bars_keep_their_widths_in_a_wide_container() { + let (_, ids, (detail, settings, unknown)) = frames(Vec::new(), |ui| { + let detail = crate::panels::denoise::detail_bar(ui, 3, 10).rect.width(); + let settings = crate::panels::denoise::settings_bar(ui, 3, 10, "Denoising a.jpg: 3 of 10 tiles".into()).rect.width(); + let unknown = crate::panels::denoise::detail_bar(ui, 0, 0).rect.width(); + (detail, settings, unknown) + }); + assert_eq!((detail, settings, unknown), (240.0, 380.0, 240.0)); + assert!(ids.iter().any(|(id, _)| id == "denoise:settingsProgress")); +} + +#[test] +fn the_denoise_bar_label_and_its_accessible_value_agree() { + // the number drawn on the bar is the one announced, and exact decimals stay exact + for (done, total, says) in + [(3, 7, 42.0), (53, 100, 53.0), (59, 100, 59.0), (29, 100, 29.0), (99, 100, 99.0), (100, 100, 100.0), (999, 1000, 99.0)] + { + let (nodes, _, _) = frames(Vec::new(), |ui| { + crate::panels::denoise::detail_bar(ui, done, total); + }); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}")); + let shown: f64 = bar.label.rsplit(' ').next().unwrap().trim_end_matches('%').parse().unwrap(); + assert_eq!((shown, bar.value), (says, Some(says)), "{done}/{total}: {bar:?}"); + } +} + +#[test] +fn the_sam_bar_text_and_its_accessible_value_agree() { + for (done, total, says) in [(53, 100, 53.0), (59, 100, 59.0), (29, 100, 29.0), (999, 1000, 99.0), (100, 100, 100.0)] { + let (nodes, _, _) = frames(Vec::new(), |ui| crate::panels::masking::sam_download_bar(ui, done, total, 200.0).rect.width()); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}")); + assert_eq!(bar.label, format!("SAM 3: {says} %"), "{done}/{total}"); + assert_eq!(bar.value, Some(says), "{done}/{total}"); + } +} + +#[test] +fn an_activity_bar_is_named_after_its_task() { + let task = lightcraft_engine::activity::TaskInfo { + id: 1, + kind: "export", + label: "Exporting".into(), + done: 3, + total: 25, + unit: lightcraft_engine::activity::Unit::Count, + detail: String::new(), + cancellable: true, + cancelling: false, + age_ms: 600, + }; + let (nodes, _, _) = frames(Vec::new(), |ui| crate::panels::activity::bar(ui, &task, 200.0).rect.width()); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}")); + assert_eq!(bar.label, "Exporting"); + assert_eq!(bar.value, Some(12.0)); +} diff --git a/crates/ui-egui/src/tests_rating.rs b/crates/ui-egui/src/tests_rating.rs new file mode 100644 index 000000000..d473508d3 --- /dev/null +++ b/crates/ui-egui/src/tests_rating.rs @@ -0,0 +1,238 @@ +//! The star rating: clicking sets the rating, clicking the current one clears it, the row is one +//! Tab stop, the arrow keys step it, and screen readers hear every star. + +use egui::{Pos2, Rect, vec2}; + +use crate::rating::Rating; + +/// Draw `build` for a few frames. After the first, click the centre of star `click` (1 to 5) if given, +/// then press `keys`. Returns every value `build` reported and the AccessKit +/// (role, name) pairs. +fn drive(click: Option<u8>, keys: &[egui::Key], build: impl Fn(&mut egui::Ui) -> Option<u8>) -> (Vec<u8>, Vec<(String, String)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut reported = Vec::new(); + let mut nodes = Vec::new(); + let mut rects: Vec<(String, Rect)> = Vec::new(); + for frame in 0..6 { + let mut events = Vec::new(); + if frame == 1 + && let Some(n) = click + { + let want = format!("star:t:{n}"); + let at = rects.iter().find(|(id, _)| *id == want).map(|(_, r)| r.center()).unwrap_or(Pos2::ZERO); + events.push(egui::Event::PointerMoved(at)); + events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + // one key per frame from the third on, so Tab has moved the focus before an arrow arrives + if frame >= 2 + && let Some(k) = keys.get(frame - 2) + { + events.push(egui::Event::Key { key: *k, physical_key: Some(*k), pressed: true, repeat: false, modifiers: Default::default() }); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(v) = build(ui) { + reported.push(v); + } + }); + out.textures_delta.clear(); + let seen = crate::widgets::take_registry(&ctx); + if rects.is_empty() { + rects = seen; + } + if let Some(update) = out.platform_output.accesskit_update.take() { + nodes = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect(); + } + } + (reported, nodes) +} + +fn row(rating: u8) -> Rating { + Rating::new("t", rating) +} + +#[test] +fn clicking_a_star_sets_that_rating() { + let (got, _) = drive(Some(4), &[], |ui| row(2).show(ui)); + assert_eq!(got, vec![4]); +} + +#[test] +fn clicking_the_current_rating_clears_it() { + let (got, _) = drive(Some(3), &[], |ui| row(3).show(ui)); + assert_eq!(got, vec![0]); +} + +#[test] +fn hovering_alone_changes_nothing() { + // the preview is only painted; the rating is reported on click + let (got, _) = drive(None, &[], |ui| row(2).show(ui)); + assert!(got.is_empty()); +} + +/// How many stars `Rating::show` paints filled (in the star colour) after the pointer has moved to +/// the centre of star `hover` (1 to 5), if given. A filled star is ten triangles of one colour. +fn filled_stars(rating: u8, hover: Option<u8>) -> usize { + fn count(shape: &egui::Shape, colour: egui::Color32) -> usize { + match shape { + egui::Shape::Path(path) => usize::from(path.fill == colour), + egui::Shape::Vec(shapes) => shapes.iter().map(|shape| count(shape, colour)).sum(), + _ => 0, + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let colour = crate::theme::Tokens::get(&ctx).star; + let mut at = None; + let mut filled = 0; + for frame in 0..4 { + let mut events = Vec::new(); + if frame >= 1 + && let Some(at) = at + { + events.push(egui::Event::PointerMoved(at)); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let _ = row(rating).show(ui); + }); + out.textures_delta.clear(); + let seen = crate::widgets::take_registry(&ctx); + if let (Some(n), true) = (hover, frame == 0) { + let want = format!("star:t:{n}"); + at = seen.iter().find(|(id, _)| *id == want).map(|(_, r)| r.center()); + assert!(at.is_some(), "star {n} is registered"); + } + filled = out.shapes.iter().map(|clipped| count(&clipped.shape, colour)).sum::<usize>() / 10; + } + filled +} + +#[test] +fn hovering_a_star_paints_the_rating_a_click_would_set() { + // the row shows 2 filled stars; the pointer over star 4 shows 4, and over star 1 shows 1 + assert_eq!(filled_stars(2, None), 2); + assert_eq!(filled_stars(2, Some(4)), 4); + assert_eq!(filled_stars(2, Some(1)), 1, "hovering a lower star previews the lower rating"); +} + +#[test] +fn a_screen_reader_hears_every_star_with_its_state() { + let (_, nodes) = drive(None, &[], |ui| row(2).show(ui)); + for n in 1..=5 { + let name = format!("{n} ★"); + assert!(nodes.iter().any(|(role, label)| role == "Button" && *label == name), "{name}: {nodes:?}"); + } +} + +#[test] +fn the_arrow_keys_step_the_rating_of_a_focused_star() { + // Tab focuses the first star; the row shows 3, so → asks for 4 and ← for 2 + let (got, _) = drive(None, &[egui::Key::Tab, egui::Key::ArrowRight], |ui| row(3).show(ui)); + assert_eq!(got, vec![4], "{got:?}"); + let (got, _) = drive(None, &[egui::Key::Tab, egui::Key::ArrowLeft], |ui| row(3).show(ui)); + assert_eq!(got, vec![2], "{got:?}"); +} + +#[test] +fn arrow_keys_do_nothing_when_no_star_is_focused() { + let (got, _) = drive(None, &[egui::Key::ArrowRight], |ui| row(3).show(ui)); + assert!(got.is_empty(), "{got:?}"); +} + +/// Feed the shown rating back each frame, with other focusable widgets on both sides. +fn step_with_neighbours(start: u8, keys: &[egui::Key]) -> Vec<u8> { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let rating = std::cell::Cell::new(start); + let mut seen = Vec::new(); + // a key every frame, Tab then an arrow straight away + for frame in 0..keys.len() + 3 { + let mut events = Vec::new(); + if let Some(k) = frame.checked_sub(1).and_then(|f| keys.get(f)) { + events.push(egui::Event::Key { key: *k, physical_key: Some(*k), pressed: true, repeat: false, modifiers: Default::default() }); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| { + let _ = ui.button("left neighbour"); + if let Some(r) = row(rating.get()).show(ui) { + rating.set(r); + } + let _ = ui.button("right neighbour"); + }); + }); + out.textures_delta.clear(); + seen.push(rating.get()); + } + seen +} + +#[test] +fn arrows_keep_stepping_between_other_focusable_widgets() { + use egui::Key::*; + // Tab, Tab: the left neighbour, then star 1 + let seen = step_with_neighbours(5, &[Tab, Tab, ArrowLeft, ArrowLeft, ArrowLeft, ArrowLeft, ArrowLeft]); + assert_eq!(seen.last(), Some(&0), "{seen:?}"); + let seen = step_with_neighbours(0, &[Tab, Tab, ArrowRight, ArrowRight, ArrowRight, ArrowRight, ArrowRight, ArrowLeft]); + assert_eq!(seen.last(), Some(&4), "{seen:?}"); +} + +#[test] +fn a_rating_row_is_one_tab_stop() { + use egui::Key::Tab; + // left neighbour, one stop for the five stars, right neighbour: the third Tab is already past the row + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut focused = Vec::new(); + for frame in 0..5 { + let events = if frame >= 1 { + vec![egui::Event::Key { key: Tab, physical_key: Some(Tab), pressed: true, repeat: false, modifiers: Default::default() }] + } else { + Vec::new() + }; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut right = None; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| { + let _ = ui.button("left neighbour"); + let _ = row(3).show(ui); + right = Some(ui.button("right neighbour").id); + }); + }); + out.textures_delta.clear(); + focused.push(right.is_some_and(|id| ctx.memory(|m| m.has_focus(id)))); + } + // frames 1 to 3 press Tab: left neighbour, the row, right neighbour + assert_eq!(focused, vec![false, false, false, true, false], "{focused:?}"); +} + +#[test] +fn a_rating_changed_from_outside_moves_the_focus_to_the_tab_stop() { + use egui::Key::Tab; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let rating = std::cell::Cell::new(2u8); + let mut stars_focused = Vec::new(); + for frame in 0..5 { + // frame 1: Tab focuses the row's stop (star 2); frame 2: the rating changes elsewhere to 5 + if frame == 2 { + rating.set(5); + } + let events = if frame == 1 { + vec![egui::Event::Key { key: Tab, physical_key: Some(Tab), pressed: true, repeat: false, modifiers: Default::default() }] + } else { + Vec::new() + }; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let _ = row(rating.get()).show(ui); + stars_focused = (0..5).map(|i| ctx.memory(|m| m.has_focus(ui.scope_id().with(("rating-star", "t", i as u8))))).collect(); + }); + out.textures_delta.clear(); + } + assert_eq!(stars_focused, vec![false, false, false, false, true], "{stars_focused:?}"); +} diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 24aa0d728..f4f54c3ad 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -16,9 +16,13 @@ const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); fn grid() -> Headless { + grid_sized([1000.0, 700.0]) +} + +fn grid_sized(size: [f32; 2]) -> Headless { let services = Services { png: None, ..Default::default() }; let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let mut h = Headless::new(app, [1000.0, 700.0], 1.0); + let mut h = Headless::new(app, size, 1.0); h.step(); h } @@ -237,8 +241,8 @@ fn playback_settings_drive_the_slideshow_and_bad_ones_are_refused() { reveal(&mut h, "check:settings.slideshowRandom"); let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T); assert_eq!(r["ok"], true, "{r}"); - h.step(); - assert!(h.app.session.slideshow.random_order, "the checkbox goes through slideshow.playback"); + // (waits for it: one frame isn't always enough on a loaded machine) + assert!(h.step_until(T, |h| h.app.session.slideshow.random_order), "the checkbox goes through slideshow.playback"); h.settle(SETTLE); } @@ -258,20 +262,35 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { h.settle(SETTLE); } -/// Scroll the Settings ▸ Slideshow column until widget `id` is in view (near its top): the -/// sections are taller than the dialog. +/// Scroll the Settings ▸ Slideshow page until widget `id` is in view (in the middle of the room +/// for the page): the sections are taller than the dialog. (The slide preview and its choice are pinned +/// above the page and are in view already.) fn reveal(h: &mut Headless, id: &str) { let find = |h: &Headless, w: &str| h.app.widgets.iter().find(|(i, _)| i == w).map(|(_, r)| *r); for _ in 0..40 { h.step(); - let col = find(h, "slideshow:preview").expect("the Slideshow settings are open"); + let room = find(h, "settings:pageRoom").expect("the Slideshow settings are open"); + // (the preview and its Photo / Intro / Ending choice are pinned above the page: always in + // view, never scrolled to) + let pinned = id == "slideshow:preview" || id.starts_with("button:settingsSlideshowPreviewShows-"); + if pinned && find(h, id).is_some_and(|w| w.bottom() <= room.top()) { + return; + } let dy = match find(h, id) { - Some(w) if w.center().y >= col.top() + 10.0 && w.center().y <= col.top() + 340.0 => return, - Some(w) => (col.top() + 80.0 - w.center().y).clamp(-500.0, 500.0), + Some(w) if w.center().y >= room.top() + 10.0 && w.center().y <= room.bottom() - 10.0 => { + // (in view and still: the page eases to where it was scrolled to) + for _ in 0..3 { + h.step(); + } + if find(h, id).is_some_and(|now| (now.center().y - w.center().y).abs() < 0.5) { + return; + } + continue; + } + Some(w) => (room.center().y - w.center().y).clamp(-500.0, 500.0), None => -300.0, }; - // (the pointer over the settings column, left of the preview) - let r = h.request("ui.hoverWidget", json!({"id": "slideshow:preview", "fx": -1.0, "fy": 0.5}), T); + let r = h.request("ui.hoverWidget", json!({"id": "settings:pageRoom"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.scroll", json!({"dy": dy}), T); assert_eq!(r["ok"], true, "{r}"); @@ -373,9 +392,19 @@ fn settings_shows_the_slide_layout_and_a_live_preview() { run(&mut h, "slideshow.layout", json!({"margins": 0.2, "backdropColor": "#00ff00"})); assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); h.step(); - let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); - let img = h.paint(); - let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize); + // (the preview is pinned above the page: in view already) + reveal(&mut h, "slideshow:preview"); + // (painted once the new preview is on screen: one frame isn't always enough on a loaded machine) + let t0 = std::time::Instant::now(); + let c = loop { + let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); + let img = h.paint(); + let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize); + if (c[1] > 200 && c[0] < 60) || t0.elapsed() > T { + break c; + } + h.step(); + }; assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}"); h.settle(SETTLE); } @@ -592,6 +621,7 @@ fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { assert_eq!(crate::panels::slide_titles::preview_shows(&h.view.ctx), Some(lightcraft_engine::slideshow::Title::Intro)); run(&mut h, "slideshow.titles", json!({"intro": {"color": "#ff8000", "content": "none"}})); assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == PhotoId(0)))); + reveal(&mut h, "slideshow:preview"); assert!(h.step_until(SETTLE, |h| { let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); let img = h.paint(); @@ -606,6 +636,7 @@ fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { h.step(); assert!(has(&h, "field:slideshowIntroText")); // back to the photo's slide + reveal(&mut h, "button:settingsSlideshowPreviewShows-0"); let r = h.request("ui.clickWidget", json!({"id": "button:settingsSlideshowPreviewShows-0"}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); @@ -643,6 +674,8 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { let before = SlideLook::of(&h.app.session); let r = h.request("ui.hoverWidget", json!({"id": "template:builtin:gallery-wall"}), T); assert_eq!(r["ok"], true, "{r}"); + // (the pointer is over the row one frame after it moved there, and the page has settled the next) + h.step(); h.step(); assert_eq!(crate::panels::slide_templates::hovered(&h.view.ctx).as_deref(), Some("builtin:gallery-wall")); let grey = |h: &Headless| { @@ -655,16 +688,19 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert_eq!(SlideLook::of(&h.app.session), before, "hovering changes nothing"); assert!(h.app.session.slide_templates_previous.is_none()); // a click applies it + reveal(&mut h, "template:builtin:gallery-wall"); let r = h.request("ui.clickWidget", json!({"id": "template:builtin:gallery-wall"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(h.app.session.slide_overlays.rating_stars.enabled && h.app.session.slide_layout.stroke_border); assert!(has(&h, "template:previous"), "Previous settings appear"); // save as a template of our own + reveal(&mut h, "field:slideshowTemplateName"); let r = h.request("ui.clickWidget", json!({"id": "field:slideshowTemplateName"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.text", json!({"text": "Holiday / 2026"}), T); assert_eq!(r["ok"], true, "{r}"); + reveal(&mut h, "button:slideshowTemplateSave"); let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateSave"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -673,6 +709,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert!(has(&h, "template:user:Holiday _ 2026")); // update it from its menu with changed settings run(&mut h, "slideshow.layout", json!({"backdropColor": "#123456"})); + reveal(&mut h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -685,6 +722,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert_eq!(h.app.session.slide_template("user:Holiday _ 2026").unwrap().look.layout.backdrop_color, [0x12, 0x34, 0x56]); // rename it in place + reveal(&mut h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -703,6 +741,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert!(has(&h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::<Vec<_>>()); // delete it (after confirming) + reveal(&mut h, "template:user:Summer"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Summer", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -716,6 +755,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert!(!dir.join("Summer.json").exists()); // back to the settings before Gallery Wall + reveal(&mut h, "template:previous"); let r = h.request("ui.clickWidget", json!({"id": "template:previous"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -723,3 +763,25 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.settle(SETTLE); let _ = std::fs::remove_dir_all(dir); } + +/// Hovering a template in the Template Browser previews it in the slide preview, so the preview +/// must be in view while the templates (further down the page) are: it is pinned above the page, +/// under the tab bar, and does not scroll away with it. +#[test] +fn the_slide_preview_stays_in_view_while_the_templates_are_browsed() { + // (900 x 560 is the least the desktop app's window is) + for size in [[1000.0, 700.0], [900.0, 560.0]] { + let mut h = grid_sized(size); + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + let find = |h: &Headless, w: &str| h.app.widgets.iter().find(|(i, _)| i == w).map(|(_, r)| *r); + let tabs = find(&h, "button:settingsTab-slideshow").expect("the tab bar"); + // down to the last section of the page: the templates are far above the preview's old place + reveal(&mut h, "button:slideshowExportPdf"); + let preview = find(&h, "slideshow:preview").expect("the preview is still drawn"); + let room = find(&h, "settings:pageRoom").unwrap(); + assert!(preview.height() > 40.0, "{size:?}: {preview:?}"); + assert!(preview.top() >= tabs.bottom(), "{size:?}: under the tab bar, not scrolled up behind it: {preview:?} {tabs:?}"); + assert!(preview.bottom() <= room.top() + 1.0, "{size:?}: above the page, not in it: {preview:?} {room:?}"); + } +} diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index 2ea310b04..3e1ce6e37 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -163,6 +163,10 @@ fn missing_recent_libraries_are_greyed_out_and_pruned_and_the_list_is_capped() { let mut app = app_in(&dir); let gone = dir.join("gone").to_string_lossy().into_owned(); app.ui.settings.recent_libraries = vec![gone.clone()]; + // the menu takes what a background check found (issue #1290 review): let it answer first + let ctx = egui::Context::default(); + crate::panels::library_problem::logic(&mut app, &ctx); + assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(20)), "the check answered"); let disabled = recent_submenu(&app) .iter() .any(|n| matches!(n, crate::menubar::MenuNode::Item { params, enabled: false, .. } if params["path"] == gone.as_str())); @@ -254,3 +258,22 @@ fn library_names_must_work_as_folder_names_on_every_system() { assert_eq!(app.session.library.as_ref().unwrap().dir, dir.join("Trip 2026")); let _ = std::fs::remove_dir_all(&dir); } + +/// The web build's page title names the active photo (a host embedding the page reads it to know +/// which document a drop opened), with • while changes are not yet in storage (issue #2397). +#[test] +fn page_title_names_the_active_photo_and_unsaved_changes() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let ids = app.session.visible_cloned(); + let name = |app: &LightcraftApp, id: lightcraft_catalog::PhotoId| app.session.catalog.photo(id).unwrap().file_name.clone(); + app.run("library.select", json!({"ids": [ids[0].0]})).unwrap(); + assert_eq!(app.page_title(false), format!("{} — LightCraft", name(&app, ids[0]))); + assert_eq!(app.page_title(true), format!("{} • — LightCraft", name(&app, ids[0]))); + app.run("library.select", json!({"ids": [ids[1].0]})).unwrap(); + assert_eq!(app.page_title(false), format!("{} — LightCraft", name(&app, ids[1]))); + // no photo: the window title + let empty = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + assert!(empty.session.active().is_none()); + assert_eq!(empty.page_title(false), "LightCraft"); + assert_eq!(empty.page_title(true), "LightCraft •"); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6a532f210..e55008efd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -44,6 +44,9 @@ pub struct Tokens { pub grid_bg: Color32, pub cell: Color32, pub cell_selected: Color32, + /// A thumbnail's place while its picture is still being made: static, apart from the grid, the + /// cells and the canvas in every theme. + pub skeleton: Color32, pub divider: Color32, pub inset: Color32, pub field: Color32, @@ -125,6 +128,7 @@ impl Default for Tokens { grid_bg: Color32::from_rgb(0x0f, 0x0f, 0x0f), cell: Color32::from_rgb(0x1c, 0x1c, 0x1c), cell_selected: Color32::from_rgb(0x2d, 0x2d, 0x2d), + skeleton: Color32::from_rgb(0x25, 0x25, 0x25), divider: Color32::from_rgb(0x1c, 0x1c, 0x1c), inset: Color32::from_rgb(0x23, 0x23, 0x23), field: Color32::from_rgb(0x22, 0x22, 0x22), @@ -172,6 +176,7 @@ impl Tokens { grid_bg: rgb(0x080808), cell: rgb(0x121212), cell_selected: rgb(0x262626), + skeleton: rgb(0x1d1d1d), divider: rgb(0x0e0e0e), inset: rgb(0x181818), field: rgb(0x161616), @@ -189,6 +194,7 @@ impl Tokens { grid_bg: rgb(0xd9d9d9), cell: rgb(0xcbcbcb), cell_selected: rgb(0xb4b4b4), + skeleton: rgb(0xa6a6a6), divider: rgb(0xc4c4c4), inset: rgb(0xdcdcdc), field: rgb(0xf6f6f6), @@ -219,6 +225,7 @@ impl Tokens { grid_bg: rgb(0xf2f2f2), cell: rgb(0xe8e8e8), cell_selected: rgb(0xd2d2d2), + skeleton: rgb(0xbcbcbc), divider: rgb(0xe0e0e0), inset: rgb(0xf0f0f0), field: rgb(0xffffff), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 83307df11..ab8ae253a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -795,41 +795,6 @@ pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: if !tooltip.is_empty() { resp.on_hover_text(tooltip) } else { resp } } -/// Star rating row (5 stars); returns a new rating when clicked (clicking the current rating clears). -pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option<u8> { - let t = Tokens::get(ui.ctx()); - let mut out = None; - let mut hover_n = None; - let mut rects = [Rect::NOTHING; 5]; - for (i, slot) in (0..5u8).zip(rects.iter_mut()) { - let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); - crate::widgets::announce_selected(&resp, format!("{} ★", i + 1), i < rating); - register(ui.ctx(), format!("star:{id}:{}", i + 1), r); - if resp.hovered() { - hover_n = Some(i + 1); - } - if resp.clicked() { - out = Some(if rating == i + 1 { 0 } else { i + 1 }); - } - *slot = r; - } - // Hover previews the rating a click would set: stars 1..=hovered light up. The hovered star also gets the - // icon_button hover square, so hovering the current rating (where the preview looks the same) still shows feedback. - for (i, r) in (0..5u8).zip(rects) { - if hover_n == Some(i + 1) { - ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); - } - let filled = star_lit(i, rating, hover_n); - paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon }); - } - out -} - -/// Whether the star at zero-based index `i` is drawn filled: the hovered star's rating previews over the current one. -fn star_lit(i: u8, rating: u8, hover: Option<u8>) -> bool { - i < hover.unwrap_or(rating) -} - /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { dropdown_within(ui, id, text, font, color, f32::INFINITY) @@ -868,16 +833,6 @@ mod tests { assert!(preview_only_explanation("unsupported CR3").contains("unsupported CR3")); } - /// Issue #647: hovering a star previews the rating a click would set. - #[test] - fn hovered_star_previews_its_rating() { - let lit = |rating, hover| (0..5u8).filter(|&i| star_lit(i, rating, hover)).count(); - assert_eq!(lit(2, None), 2, "no hover shows the rating"); - assert_eq!(lit(2, Some(4)), 4); - assert_eq!(lit(5, Some(1)), 1); - assert_eq!(lit(0, Some(3)), 3); - } - #[test] fn nudges_are_a_sensible_step() { let spec = |id, min, max, step| ControlSpec { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 91c2ea079..d45a76cb2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -90,6 +90,7 @@ filmstrip is visible. | `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state | | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog (confirm runs its action; Synchronize Folder on a folder that is up to date answers `{"upToDate": true}` and starts nothing) | | `ui.resize` | `{width, height}` | Resize the window | +| `ui.dropFiles` | `{paths: [..], hover?}` | Files dragged from the file manager. `hover: true` makes every following frame report `paths` as hovered over the window (the drop card appears, `ui.widgets` lists `dropOverlay`) until another `ui.dropFiles` replaces them; `{paths: [], hover: true}` ends the hover. Without `hover` the files are dropped: the hover ends and the next frame receives them as dropped files, exactly as an OS drop arrives (photos and folders open the import review, or import at once with Settings ▸ Import ▸ Import dropped files without review). An empty path stands for a file whose path the platform doesn't tell | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. A `path` that did not exist when the request was admitted is written with create-new semantics and is never replaced if a file (including a newly imported original) appears there while the capture is queued; an existing ordinary file may be refreshed. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | @@ -165,7 +166,42 @@ catalog, a missing drive), the session starts empty and in memory — never with to do: `ui.inspect` → `libraryProblem` is `{path, error, temporarySession, pendingImport}` (else `null`); its buttons are `button:libraryRetry`, `button:libraryChoose`, `button:libraryTemporary` (Continue Without Saving) and `button:libraryQuit`. A temporary session shows a banner (`indicator:temporarySession`, `button:libraryReopen`) and -writes nothing. `app.openLibrary` opening a library ends it. +writes nothing. `app.openLibrary` opening a library ends it. `pendingImport` counts the files waiting for a +library: those given on the command line, and photos dropped on the window while it (or the launch list below) is +up (desktop app); they are imported into the library that opens, or into the temporary session. Presets dropped +then are refused with a toast. + +### Choosing the library at launch + +With Settings ▸ General ▸ At launch set to Ask me (`ui.set {"settings": {"atLaunch": "ask"}}`; `"last"`, the +default, opens the last library), or Alt/Option held during the first 2 s after the window appears, the desktop app +shows a window listing the recent libraries (issue #1290). `--library` and `LIGHTCRAFT_LIBRARY` skip it, and so does +`--memory`; `lightcraft-cli` never shows it. `ui.inspect` → `libraryProblem.chooser` is `{libraries: [{path, +lastOpened, available, checking, reason}], selected, libraryOpen}` (else `null`; `libraryOpen: true` when it was opened +over the last library with Alt). Each library is checked read-only on a worker thread of its own, never on the UI +thread (the folder and its catalog file are looked at, never locked or written, so a library another program has +open shows as available and Open reports who has it in `libraryProblem.error`). `checking: true` until its check +answers (the row says *Checking…*, then that the drive isn't answering after 5 s); `available` is `false` only +once a check found a `reason`, so a row still checking can be opened. The selection starts on the last library and +moves to the first one that can be opened once every check has answered, unless it was moved meanwhile. Each row is the widget `libraryRecent:<index>`; the buttons are `button:libraryOpen`, +`button:libraryOpenAnother` (the folder dialog of `app.openLibrary`), `button:libraryNew` (`app.newLibrary`), +`button:libraryForget` (Remove from List) and `button:libraryQuit`; `ui.key` ArrowUp / ArrowDown move the selection, +Enter opens it and Escape quits (over an open library: closes the window and keeps it). `app.openLibrary {path}` +opening a library ends it. While it shows, the settings are saved as usual (a removal is kept). + +`app.recentLibraries` returns `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last +background check found (`false`: moved, on a disconnected drive, not a folder or no longer a library; `null`: not +checked yet, or the check couldn't tell, e.g. a folder that can't be listed, an unreadable catalog or a lookup that +failed) and `checking` whether one +is running: the command never looks at a folder itself. A failed open never marks a library missing (another program +may have it). Recent libraries are checked in the +background when the app starts (the first 8) and when the launch window opens; one not found is looked for again every +30 s (a drive plugged back in), the others only when the window comes back to the front (a timer would wake external +drives and shares). File ▸ Open Recent Library greys out only an entry a check found missing (not there, not a folder, +no library in it); an unreadable catalog, a folder that refuses to be listed, a failed open or a lookup that failed +(a share that timed out) doesn't grey it out. `lastOpened`: ISO 8601, `null` when not known; kept in `ui.json` → +`settings.recentLibrariesOpened`. `{remove: path}` forgets one entry (an error if the list +doesn't hold it) and `{clear: true}` (or `app.clearRecentLibraries`) all of them; nothing on disk is touched. ### Open Library on a Lightroom catalog @@ -173,12 +209,29 @@ writes nothing. `app.openLibrary` opening a library ends it. folder (it holds a `*.lrcat`), one of its `*.lrdata` / `*.lrcat-data` folders or `Backups/`, the `.lrcat` itself or its `-wal` / `-shm` / `.lock` files, it keeps the library open now and returns `{opened: false, lightroomCatalog: <the .lrcat found, or null>, path}`; the window shows a dialog whose `button:dialogOk` (Import Lightroom Catalog…, or -`ui.dialog.confirm`) runs `file.importLightroom` with that catalog (no catalog found: it asks for one) and -`button:dialogCancel` (`ui.dialog.cancel`) does nothing. A folder that is a LightCraft library (`catalog.snap` / `catalog.log`) always +`ui.dialog.confirm`) runs `file.importLightroom` with that catalog (no catalog found: it asks for one), which then +asks where the catalog goes when `settings.lightroomImport` is `"ask"` (next section), and `button:dialogCancel` (`ui.dialog.cancel`) does nothing. A folder that is a LightCraft library (`catalog.snap` / `catalog.log`) always opens as one. With no library open (the library problem window) it is an error instead. The engine refuses the same places (`EngineError::LightroomCatalog`, so `lightcraft-cli --library` and `mcp --library` too). `library.importLightroom {status: true}` reports the catalog's `path` after the task ends as well as while it runs. +### Import Lightroom Catalog: where the catalog goes + +`file.importLightroom {path?, into?, name?, location?}` (File ▸ Import Lightroom Catalog…, issue #2672). With `into` +it acts without asking: `"open"` imports into the library open now (`library.importLightroom`); `"new"` creates the +library `name` (default: the catalog's file name without `.lrcat`, or `Travel 2`… when taken) in the folder +`location` (a full path; default: the open library's parent folder) under `app.newLibrary`'s rules, opens it and +imports there, returning the import's start with `library: {path, name, photos}`. A catalog file that isn't there is +refused before anything is made; so is a new library while an import runs. Without `into`, a library on disk with +`settings.lightroomImport` `"ask"` (the default) opens a window and returns `{asking: true, path, into}` (`into`: the +choice it opens with, `"open"` when the library is empty, else `"new"`). Its widgets: +`radio:lightroomDestination-open`, `radio:lightroomDestination-new`, `field:lightroomLibraryName`, +`field:lightroomLibraryLocation`, `button:lightroomLibraryChoose` (where there is a folder dialog), `check:lightroomDontAsk` (with Merge only; sets +`"alwaysMerge"`), `button:dialogOk` / `ui.dialog.confirm` (which stays open on an error such as a folder that isn't +empty) and `button:dialogCancel`. With `"alwaysMerge"` (`ui.set {"settings": {"lightroomImport": "alwaysMerge"}}`, +or Settings ▸ General `button:settingsLightroomImport-0|1`), or in a session in memory, it imports into the open +library without asking. + ## External controllers External apps can use this channel to send commands and poll the running desktop app for feedback diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 22417aa6f..d34b931ce 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1,5 +1,18 @@ # LightCraft: egui 0.37 migration plan +Current landing status, 2026-10-25: the migration integrates main `38f8d2508700fa4938295e865d3fc21be07266ba` and uses egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc` and craft-ui `25da1314d443171b4adeaad2cd9454b273f35c6f`. Cargo.lock SHA-256 is `6336b4aa2ac9df57cb8feaafb097b30fa7228ef427f5e213fac1a2a7875e4181`; the independent craft-docpath pin remains selected in the lockfile. Release workflows use craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`. + +Fresh static review verified preservation of the captured main and the final dependency graph. The final managed CI run `egui037-lightcraft-library-frame-ci-20261025-0551` passed all eight stages. Including its focused library tests, the log records 5,083 passing test executions, zero failures and 42 ignored across 142 summaries. The CI prerequisite report separately identifies 149 tests that returned without exercising their checks (corpus137, models8, fonts2, GPU1, system1); these are coverage gaps. Earlier passing counts below qualify their historical snapshots only. The icon-font attribution URL now identifies the selected egui source; font bytes are unchanged. Visible GUI, browser/platform runtime, native IME, final packages and paired performance remain separate acceptance gates. Human review and human merge are required. + +The review packet separately records inherited default-font notice coverage and Inter licence staging gaps. Nix font-pin drift is fixed as described below; the other inherited gaps are not established as fixed by the migration or by source-level notice routing checks. + +Current status note (2026-10-25): `flake.nix` now points at craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`, matching CI, the release workflow and the standalone packaging helpers, and `flake.lock` was regenerated by Nix 2.26.3 using `nix flake update craft-fonts` against the canonical GitHub revision. Only the craft-fonts node changed; nixpkgs stayed pinned. Linux x86_64 flake evaluation passed with `nix flake check --no-build --no-write-lock-file` (job `egui037-lightcraft-nix-eval-20261025-0495`). No Nix package build, executed formatting derivation, or other architecture is claimed. The inherited gaps above (egui default-font licence coverage for Hack, Ubuntu-Light and NotoEmoji-Regular; the Inter OFL packaging omission) are not migration-introduced, and this landing updates the craft-fonts notices and attribution text only. + +Library-check results now remain visible for the frame in which they arrive, so a zero-delay refresh cannot immediately replace a fresh missing-library answer. The scheduling fence uses the root UI frame number across all layout passes; focused regressions cover the single-pass and forced-multipass cases with isolated temporary directories. Fresh static review accepted the delivery-frame behavior. + +The original planning and earlier qualification record follows. Its pins, hashes, paths and passing counts are historical. + + ## Current implementation status — 2026-10-24 The implementation is now pinned to egui `173cc7375d35dc5fbc5b5d0994fdf787f547c5d1`, diff --git a/docs/focus-points.md b/docs/focus-points.md index 61c562b03..2171a7c15 100644 --- a/docs/focus-points.md +++ b/docs/focus-points.md @@ -6,7 +6,7 @@ zooms to it, so checking whether the subject is sharp takes one key while cullin ## Using it -- **View ▸ Show Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in +- **View ▸ Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in Detail, Compare and Survey. It follows Rotate, flips and the crop; a point cropped away is not drawn. Off by default; the choice is saved with the app's settings. - **View ▸ Zoom to Focus Point** (`view.zoomToFocus {on?}`, on by default): Toggle Zoom (Z or Space) zooms in diff --git a/docs/gaps.md b/docs/gaps.md index 329dcf531..ceea3724f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 28: ⌘L Enable Filters switches the filter off and on; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -23,11 +23,11 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | -| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | +| 9 B | **Tests don't run in CI on macOS or Windows**: Linux x64 runs `cargo xtask ci` on every PR and push to main (`.github/workflows/ci.yml`) and FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (ci, freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU; macOS / Windows jobs: #1148 | regressions in `cfg(target_os)` code reach macOS and Windows users | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 551 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 regression (#702); F828 SRF now discovered and imported as preview-only, sensor decoding still missing, DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | -| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798); offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798); offline record counts and destination are now shown in a lasting import result; before an import the app asks whether the catalog merges into the open library or goes into a new library (opt-out in Settings, #2672) | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | @@ -35,7 +35,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, ERF, KDC, DCR, MEF, GPR, old FinePix (MRW decodes since 2026-10-18) | raw-parity.md | small user share each | 40–80 | [raw-parity](raw-parity.md) | -| 21 | **Localization**: 10 of 16 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages and id every message but What's New, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 135–239 (four new) | [localization-parity](localization-parity.md) | +| 21 | **Localization**: 10 of 16 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages and id every message but What's New, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Korean, Vietnamese absent; no RTL or complex shaping; exports add Thai glyphs only (#2052), with horizontal mark positioning still incomplete and Arabic fallback deferred until joining and bidi exist | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 135–239 (four new) | [localization-parity](localization-parity.md) | | 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) | | 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | @@ -43,7 +43,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | -| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | | 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target, but the Info panel and All Metadata dialog always show one photo's values with no "mixed" indication when selected photos differ — users cannot tell whether a field is uniform or mixed across the selection before editing | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, @@ -84,15 +84,22 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | +| 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours | +| 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed | +| 2026-10-22 | minor | #2052: Thai glyphs added to shared export text; gap 21 retains Thai mark positioning, Arabic joining and bidi limits. No ranked gap closes; estimates unchanged | +| 2026-10-24 | minor | LR-LIB-SMARTALBUM: relative-date views and source totals expire on the session-clock minute, including nested smart albums; ranked gaps and readiness counts unchanged | | 2026-10-22 | patch | XMP namespace-prefix collisions no longer overwrite foreign metadata; ranked gaps and estimates unchanged | | 2026-10-22 | patch | Issue #2519: PSD/PSB EXIF resource 1058 and XMP resource 1060 now use the common metadata extractor, and `.psb` files are accepted by import; no ranked gap or readiness count changes | | 2026-10-22 | minor | Gap 30 added: multi-select metadata editing — Info panel and All Metadata dialog show one photo's values with no mixed-value indication | | 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) | | 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged | +| 2026-10-19 | minor | Gap 9 shrinks: `.github/workflows/ci.yml` runs `cargo xtask ci` on Linux x64 for every PR and push to main; macOS and Windows remain | | 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h | | 2026-10-19 | minor | Gap 27: the released 0.9.1 x64 MSI is tested on Windows 11 Pro 24H2 (a fresh install and an upgrade from 0.8.0 end on the completion page, so #790 is fixed; the upgrade keeps a custom folder; Launch starts the app; removal leaves nothing behind); uninstalling from Settings → Apps still ends with no confirmation (#892, #931); hours unchanged | | 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix | | 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | +| 2026-10-14 | minor | Gap 4 follow-up: the diagnostic overlays (Visualize Spots, Point Color range, HDR range) render zoom windows too; soft proofing runs on the GPU | | 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged | | 2026-10-23 | minor | Gap 28: ⌘L / Ctrl+L (Library ▸ Enable Filters) puts the filter aside and brings it back as it was; the entry drops ⌘L; hours unchanged | | 2026-10-15 | minor | Gap 28: `\` shows / hides the Filter Bar in the grids; the entry drops `[` `]` (#509), flag cycling (#1490) and F1 (Help), already on main; ⌘L is the filter-bar key left; hours unchanged | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index c55e12965..f88f81c39 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -4,6 +4,10 @@ The CPU pipeline (`crates/pipeline`) is the reference ("oracle"). `lightcraft-gp evaluates the same stages with wgpu compute shaders (WGSL) on Metal / Vulkan / DX12. Everything is pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled in. +CPU fast guided-filter allocation measurements and reproduction commands are documented in +[guided-filter-memory.md](guided-filter-memory.md). That optimization retains the full-size base +and does not change the GPU's coefficient buffers. + Warped geometry uses a CPU-computed coverage bit mask for the source boundary. The CPU reference evaluates that boundary in double precision; GPU float rounding otherwise can turn a blank edge pixel into a photo pixel. The mask @@ -101,7 +105,8 @@ or worked around with `LIGHTCRAFT_GPU_BACKEND`. **The window's renderer and its fallback (Windows).** Before the window opens, each renderer is probed in order: DX12, Vulkan, GL, then eframe's OpenGL renderer (glow). A probe creates a device and builds egui's pipeline inside wgpu error scopes, so a driver that cannot compile egui's shaders ("The parameter is -incorrect. (0x80070057)" in `egui_pipeline`) is logged and skipped instead of killing the app. The renderer +incorrect. (0x80070057)" in `egui_pipeline`) is logged and skipped instead of killing the app; no pop-up +unless that renderer was forced (#1624). The renderer that worked is remembered in the settings folder (`window-renderer`); a start that dies before the window is up leaves `window-init.marker`, and the next start (relaunched automatically, at most 4 times) tries that renderer last. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow` (or `WGPU_BACKEND`) forces one; DX12 stays @@ -161,6 +166,8 @@ walks the drive. (log luminance, dark channel, guided-filter steps, white balance, luminance / colour NR, airlight sampling), `mask` (linear / radial / luminance / colour range / brush shapes, combine, finalize), `finish` (the whole per-pixel stage) — each mirrors a named CPU function. +- Soft proofing against an output space (`RenderRequest::proof`, with its destination and display gamut + warnings) runs in `finish`; a proof against an output-device profile (its 3D table) renders on the CPU. - Per-stage hybrid: defringe and spot removal (rare, CPU-only for now) download the resampled image, run on the CPU and upload; heuristic mask shapes (Sky, Subject, Background, …) and brushes of more than 4096 dabs are evaluated on the CPU and uploaded; sources over the buffer limit are resampled @@ -260,6 +267,13 @@ Remaining differences come from f32 vs f64 coordinate math, fast-math transcende Metal and summation order in the box filters — all far below one 8-bit step. ## Measurements +**In the app:** Help ▸ Debug ▸ Frame Rate (`view.frameRate`, `{show?}`; `LIGHTCRAFT_FPS=1` +starts with it on, never saved) draws frames per second, each frame's UI CPU time against its +time spent waiting (for vsync, or for the GPU), the latest whole-frame and zoom-window render +times, the render queue, renders thrown away as superseded, the GPU adapter and its memory, and a +graph of the last frames with a tick where a render arrived. Once a second it logs the same +numbers (`fps-hud:`), so a scripted drag (`ui.dragWidget`) can be measured from the log. + `render_bench` on `corpus/raw/arw-sony-a7m3-compressed.arw` (24 MP; loupe = 2560 px preview → 1920×1280), min of 7 runs, wall clock, on a heavily shared machine (load average 60–110 on 14 cores, so CPU numbers are pessimistic; the GPU numbers are less affected): diff --git a/docs/guided-filter-memory.md b/docs/guided-filter-memory.md new file mode 100644 index 000000000..de0fe1b62 --- /dev/null +++ b/docs/guided-filter-memory.md @@ -0,0 +1,53 @@ +# CPU fast guided-filter memory + +`local::guided_fast` computes blurred coefficients on the same reduced grid as before, then +samples them while applying `a * p + b`. It keeps the raster resizer's weights, boundary +normalization and horizontal-then-vertical accumulation order. Odd dimensions therefore use +their actual reduced/full dimension ratios. Four coefficient row pairs and one bias row are cached +per active 32-row band; adjacent output rows reuse their horizontal samples. + +The two full-resolution coefficient planes and their resize intermediates are gone. The returned +full-resolution base remains: downstream CPU tone mapping and the spatial-plane cache need it. +The step-1 filter, Box reduction, Gaussian coefficients, V2's median input, process selection and +Draft sigma cap are unchanged. The GPU implementation is unchanged; this reduces CPU memory only. + +## Reproduce + +The dev-only example includes the previous materialized algorithm and uses the workspace's +existing `dhat` allocator. No allocator or profiling dependency is added to product builds. + +```sh +cargo build --release -p lightcraft-pipeline --example guided_memory +RAYON_NUM_THREADS=1 target/release/examples/guided_memory old 6000 4000 120 15 +RAYON_NUM_THREADS=1 target/release/examples/guided_memory new 6000 4000 120 15 +RAYON_NUM_THREADS=1 target/release/examples/guided_memory old 6000 4000 120 1 --heap +RAYON_NUM_THREADS=1 target/release/examples/guided_memory new 6000 4000 120 1 --heap +``` + +If `CARGO_TARGET_DIR` is set, use that directory instead of `target` for the executable. +Arguments are mode, width, height, integer sigma and timing iterations. The example warms up +before measurement; `--heap` counts one filter call with the source allocated beforehand. +Time the runs without `--heap`: allocation profiling significantly changes timings. It reports +the minimum wall time; on a shared machine repeat when other builds/renders have stopped. +Use `LIGHTCRAFT_PROFILE=1` and `cargo xtask bench` for broader pipeline measurements. + +## Observed allocation and time + +Measured 2026-10-16 UTC, macOS aarch64, Rust 1.96.0, release build, one Rayon thread. Bytes are +requested heap bytes counted by dhat, not process RSS. Timings are minimums of 15 unprofiled +calls on a shared machine with concurrent builds; they are indicative, not a speed guarantee. + +| Dimensions / sigma / step | Old peak bytes | New peak bytes | Old total allocated bytes | New total allocated bytes | Old / new wall ms | +|---|---:|---:|---:|---:|---:| +| 6000 × 4000 / 120 / 16 | 289,125,000 | 97,780,856 | 313,083,488 | 135,643,856 | 42.135 / 45.131 | +| 1601 × 1067 / 24 / 8 | 20,822,412 | 7,331,172 | 25,102,604 | 11,562,560 | 3.359 / 3.121 | + +The 24 MP result saves about 191 MB of peak allocation (66%). Its **96,000,000-byte output is +included**; its **96,000,000-byte input is excluded**. Application RGB images, log luminance, +V2 median input and its construction temporaries, other spatial planes and caches also remain +outside this isolated measurement. More Rayon threads can hold more band scratch concurrently. +This is not a bound on total pipeline memory. + +Regression tests compare the sampled result exactly against materialized bilinear resizes for +empty/singleton/one-dimensional, odd/even and multiple-band images; flat fields, signed texture, +edges and impulses; small/full and reduced filters; and median tone bases. diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 441becd15..cd5df6b85 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -6,7 +6,41 @@ and a C SQLite runtime are not required. The source database is read-only, inclu File → Open Library… given a Lightroom catalog folder (or an `.lrdata` / `.lrcat-data` folder, or the `.lrcat`) doesn't make a LightCraft library there: it offers this import instead, with the catalog found -(`crates/engine/src/lightroom_folder.rs`, issue #891). `lightcraft-cli --library` refuses those places too. +(`crates/engine/src/lightroom_folder.rs`, issue #891), and then asks where the catalog goes as described next +(unless the question is turned off, below). +`lightcraft-cli --library` refuses those places too. + +**Where the catalog goes.** Before the import starts, the desktop app asks where the catalog goes (issue #2672), +unless Settings says to always merge (*Turning the question off*, below): + +- **Merge into the open library "<name>" (N photos)**: the catalog's photos, collections and edits join the ones + already in the library open now (the library's name is its folder's name). +- **Create a new library**: named after the catalog file (`Travel.lrcat` → "Travel", or "Travel 2" when a + "Travel" folder is already there) and placed next to the open library (in its parent folder); both can be + changed (Choose… picks the folder). LightCraft creates and opens it as File → New Library… does (the folder + must be new or empty; it is refused while another import runs), then imports the catalog into it. The library + left behind stays in File → Open Recent Library. +- **Cancel** does nothing. + +The window opens on *Merge* when the open library has no photos (there is nothing to merge with) and on *Create a +new library* when it has some. + +**Turning the question off.** Settings → General → Library → *When importing a Lightroom catalog*: *Ask where to +import it* (the default) or *Always merge into the open library* (how imports always worked before). The window's +*Don't ask again (always merge into the open library)* checkbox, offered with *Merge*, picks the second; the window +says where to turn the question back on. The choice is an app setting (`ui.json`, `settings.lightroomImport`: +`"ask"` or `"alwaysMerge"`); a settings file without it asks. + +A session with no library on disk (in memory, e.g. after Continue Without Saving) has nothing to put a new library +next to: the catalog merges into it without asking, as before. + +**Scripts and agents.** The app command `file.importLightroom {path?, into?, name?, location?}` (control channel, +`lightcraft-cli mcp --connect`) chooses without the window: `into: "open"` merges, `into: "new"` creates the library +`name` (default: the catalog's name) in the folder `location` (default: the open library's parent folder; a full +path), opens it and imports. Without `into` it shows the window (and returns `{asking: true, path, into}`, `into` +being the choice the window opens with) or merges when the setting says so. A catalog path that doesn't exist is +refused before a new library is made. The engine command `library.importLightroom` always imports into the library +open now. Equivalent commands: @@ -15,8 +49,8 @@ lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path= lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat" ``` -Import adds records to the currently open LightCraft library; it does not create a separate -folder named after the Lightroom catalog. The desktop result stays open until dismissed and +The import itself (`library.importLightroom`, `lightcraft-cli run`) adds records to the currently open LightCraft +library; only the desktop window above can make a new library for it first. The desktop result stays open until dismissed and shows the destination library, the number of records added, missing originals and warnings. **Show in Library** opens All Photos, clears filters and selects an imported record. Errors stay readable in the same result window. If another dialog is open, the result waits until that dialog closes. The result diff --git a/docs/localization-de.md b/docs/localization-de.md index aa058af9d..b31e9eff5 100644 --- a/docs/localization-de.md +++ b/docs/localization-de.md @@ -16,6 +16,32 @@ werden nicht übersetzt. Die vorhandenen Inter-Schriften enthalten die deutschen für Deutsch ist kein zusätzlicher Font-Download nötig. Beschreibungen für SAM 3 werden unverändert an das Modell übergeben; die Beispiele verwenden dessen englische Begriffe. +## Begriffe + +Eine Durchsicht aller Oberflächentexte und Meldungen (2026-10-24, mit einem Agenten erstellt und +von einem Muttersprachler stichprobenartig geprüft; die Versionshinweise nicht) hat diese Begriffe +festgelegt. Neue Texte verwenden sie ebenfalls: + +| Englisch | Deutsch | Anmerkung | +|---|---|---| +| Panel | Bedienfeld | wie in Lightroom; **Bereich** bleibt für Abschnitte (Sections) der Seitenleiste | +| Sidecar | Begleitdatei (XMP-Begleitdatei) | | +| Slide | Dia | passend zu „Diashow“ | +| Crop | Zuschnitt, zuschneiden | | +| Flag / Pick / Reject | Markierung / ausgewählt / abgelehnt | | +| Label (Color Label) | Farbmarkierung | nie nur „Markierung“, das ist die Ausgewählt/Abgelehnt-Kennzeichnung | +| Tint | Tönung | wie in Lightroom; „Tonung“ nur für Tonungsvorgaben wie Sepiatonung | +| Clipping | Beschneidung | wie Lightroom („Lichter-/Tiefenbeschneidung“); „Beschnitt“ ist ein Druckbegriff | +| Shutter Speed | Verschlusszeit | | +| Upright | Perspektivkorrektur | | +| Smart Album | Smart-Album | | +| Activity stack | Aktivitätsstapel | | + +Typografie: Gedankenstrich mit Leerzeichen als Halbgeviertstrich (` – `), Auslassungspunkte +ohne Leerzeichen direkt am Wort (`Importieren…`), deutsche Anführungszeichen („…“). „Ihre“ +nur, wo es eindeutig „von ihnen“ heißt; die Anrede ist *du*, ein Satz mit „Ihre Fotos“ am +Anfang liest sich sonst wie die Sie-Form. + ## Wartung und Prüfung Feste Texte: `crates/ui-egui/locales/de.json`. Meldungen mit Werten: diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 41669e4b7..7813b6257 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,12 @@ # Localization parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-20 · **Change:** minor (German form of address: *du*; Indonesian and Finnish interface catalogs added; Italian: every interface string translated, native-speaker corrections; Vietnamese shipped; previously minor: de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-24 · **Change:** minor (German: interface strings and formats messages reviewed, terms fixed in localization-de.md; German form of address: *du*; Indonesian and Finnish interface catalogs added; Italian: every interface string translated, native-speaker corrections; Vietnamese shipped; previously minor: de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-<code>.md` ([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md), [pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md), -[ru](localization-ru.md), [fi](localization-fi.md), [ka](localization-ka.md), [id](localization-id.md), [vi](localization-vi.md)). +[ru](localization-ru.md), [fi](localization-fi.md), [ka](localization-ka.md), [id](localization-id.md), [vi](localization-vi.md), [pl](localization-pl.md)). ## The target @@ -37,7 +37,7 @@ scripts `Latn`, `Cyrl`, `Hans`, `Hant`, `Jpan` only. The UI is egui, which has n and no complex-script shaping, so Arabic (RTL, joining) and Devanagari (conjuncts, reordering) cannot render correctly today. -Native-speaker review: no catalog has had a formal review. Several catalogs have edits from community +Native-speaker review: no catalog has had a full formal review; German's interface strings and formats messages have (2026-10-24, What's New not). Several catalogs have edits from community contributors whose commits only touch that language (ja, zh-hans, zh-hant, fr, de, ru, uk, pt-br); that is promising but is not recorded as review, so the column says `no (community edits)`. @@ -58,11 +58,11 @@ string follows that. | 7 | Portuguese (Brazil) | `pt-br` | 1,966 / 2,304 (85%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | | 8 | Indonesian | `id` | 2,555 / 2,555 (100%; 2026-10-19: every plain and formats message any catalog has, plus 69 command labels; What's New left in English) | as zh-hans | Latin (Inter covers it) | no (written with an agent; native-speaker pass pending) | partial (target: not shipped) | 2–4 h (native review, lower-layer errors) | | 9 | Japanese | `ja` | 2,383 / 2,398 (99%; 2026-10-12) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | -| 10 | German | `de` | 3,644 / 3,754 (97%; 2026-10-16: every interface message and every What's New line except 59: the distinct What's New lines whose English text has a ▸ menu path have no German entry yet and wait for #2016; the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | +| 10 | German | `de` | 3,644 / 3,754 (97%; 2026-10-16: every interface message and every What's New line except 59: the distinct What's New lines whose English text has a ▸ menu path have no German entry yet and wait for #2016; the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | partly (2026-10-24: every interface string and formats message, reviewed with an agent and spot-checked by a native speaker; What's New lines not reviewed) | partial | 1–2 h (What's New review, lower-layer errors) | | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 2,471 / 2,471 (100% key coverage of current union; intentional English like `ISO`/`OK` kept) | menus, panels, dialogs, presets, profiles, dates, formats; lower-layer errors English | Latin with stacked diacritics (Inter) | no (community contribution) | partial (catalog complete; lower-layer errors English) | 1–3 h | -## Other languages LightCraft ships (4) +## Other languages LightCraft ships (7) | Language | Code | UI strings translated | Script | Native review | Status | To `full` | |---|---|---:|---|---|---|---:| @@ -72,8 +72,9 @@ string follows that. | Italian | `it` | 2,628 / 3,278 (80%, measured 2026-10-15; every interface string is translated, the other 650 are What's New lines, left in English) | Latin (Inter) | partly (written with an agent; corrections and the 2026-10-15 additions by a native speaker) | partial (lower-layer errors English) | 3–5 h | | Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) | | Finnish | `fi` | 2,610 / 3,308 (79%, measured 2026-10-17: 2,313 plain + 297 formats; the rest is What's New) | Latin (Inter) | yes (written with an agent; native-speaker review by @roopeg) | partial (lower-layer errors English) | 3–5 h | +| Polish | `pl` | 2,159 messages + 291 formats; every format message translated (2026-10-15) | Latin (Inter) | no (AI-drafted; follow-up by a native speaker) | partial (lower-layer errors English) | 2–4 h (full native review) | -Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai +Target languages LightCraft lacks: Korean, Dutch, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai ~20–35 h on top of the shaping work. @@ -90,9 +91,9 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok ## Totals -- Bring the 10 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). +- Bring the shipped non-English catalogs to `full` (17 on 2026-10-23; the estimate was made for 10): ~55–90 h (incl. the shared work). - Add the 5 missing key languages: ~145–255 h, most of it RTL and complex shaping in egui. -- Reach the target's 16 languages: + ~50–85 h (Dutch, Polish, Swedish, Norwegian, Thai). +- Reach the target's 16 languages: + ~30–55 h (Dutch, Norwegian, Thai; Polish and Swedish ship). ## Revision history @@ -100,6 +101,7 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok |---|---|---| | 2026-10-19 | minor | Indonesian interface catalog (`id`): every plain and formats message any catalog has, plus 69 command labels; What's New left in English | | 2026-10-17 | minor | Finnish interface catalog (`fi`): 2,610 of 3,308 messages (2,313 plain + 297 formats), What's New left in English | +| 2026-10-15 | minor | Polish row added: the format messages still in English translated, counts made right for every number, dates day.month.year ([localization-pl.md](localization-pl.md)) | | 2026-10-15 | trivial | Italian (`it`): 339 interface messages and 60 format strings that were missing or still in English translated (2,628 of 3,278; only What's New left in English); native-speaker corrections: Exposure → *Esposizione*, Sharpening → *Nitidezza*, Preset → *Preset*, blurry → *sfocate* | | 2026-10-13 | minor | Remeasured denominator (2,222 plain + 249 formats = 2,471); Vietnamese catalog complete at 2,471/2,471 (status partial: lower-layer errors still English) | | 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English | @@ -108,4 +110,5 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | | 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) | | 2026-10-16 | minor | German (`de`): 217 interface strings and 862 What's New lines added (agent translations, native review welcome); re-measured 2,572 → 3,644 of 3,754 messages (80% → 97%). The denominator grew because German now translates What's New lines no other catalog has; other rows keep their earlier counts | +| 2026-10-24 | minor | German (`de`): interface strings and formats messages reviewed (agent-prepared, spot-checked by a native speaker); 108 messages corrected (Tönung, Beschneidung, Farbmarkierung vs. Markierung, Bedienfeld, Begleitdatei, Dia, en dash and ellipsis spacing, ambiguous *Ihre*); terms listed in localization-de.md; native review column `partly`, estimate 1–2 h | | 2026-10-20 | minor | German (`de`) speaks to the user with *du*; the 11 messages that used *Sie* changed, no count changes | diff --git a/docs/localization-pl.md b/docs/localization-pl.md new file mode 100644 index 000000000..445dbcf63 --- /dev/null +++ b/docs/localization-pl.md @@ -0,0 +1,56 @@ +# Polski + +Język polski (`pl`, English name **Polish**, nazwa natywna **Polski**) włącza się przez **Edytuj → Język → +Polski** albo **Ustawienia → Ogólne → Język**. Zmiana działa od razu i jest zapamiętywana przy kolejnym +uruchomieniu. Polecenie to `app.language.polish`; kanał sterowania przyjmuje `ui.set {"language":"pl"}` +oraz znaczniki `pl-PL` i `pl_PL.UTF-8`. Bez okna można ustawić `LIGHTCRAFT_LANGUAGE=pl`. + +Katalogi `crates/ui-egui/locales/pl.json` i `pl-formats.json` dodał #1100 (tłumaczenie przygotowane +z pomocą AI). Uzupełnienie opisane niżej również przygotowano z pomocą AI; przygotował je rodzimy +użytkownik języka polskiego (@lewandowskit). Profesjonalny tłumacz nie robił jeszcze korekty. + +## Konwencje + +- Menu: Plik, Edytuj, Widok, Zdjęcie, Okno, Pomoc. +- Library = Biblioteka, Develop = Wywoływanie, Preset = Ustawienie wstępne, Stack = Stos, + Masking = Maskowanie, Virtual Copy = Kopia wirtualna, Filmstrip = Pasek zdjęć. +- Clarity = Przejrzystość, Dehaze = Usuń zamglenie, Vibrance = Dynamika, Saturation = Nasycenie. +- Highlights = Światła, Shadows = Cienie, Whites = Biele, Blacks = Czernie. +- Cudzysłowy polskie: „…”. Ścieżki menu w tekstach: `Edytuj → Cofnij`. +- Data ma postać `20.09.2026` (dzień.miesiąc.rok), nagłówek dnia `Niedziela, 20.09.2026`, + miesiąca `09.2026`; czas 24-godzinny (`16:04:05`). +- **Liczby i rzeczowniki.** Polski ma trzy formy liczby mnogiej (1 zdjęcie, 2 zdjęcia, 5 zdjęć), + a mechanizm katalogów rozróżnia tylko „jeden” i „wiele”. Dlatego liczba stoi po rzeczowniku: + „Zaimportowane zdjęcia: 22”, „Usuń zdjęcia (3)”, „Oczekujące zdjęcia: 2”. Taka forma jest + poprawna dla każdej liczby. Angielska końcówka liczby mnogiej jest pochłaniana przez `{:.0}` + (jak w [localization.md](localization.md)). Po przyimku z dopełniaczem („z 10 zdjęć”, „dla 3 + zaznaczonych zdjęć”) liczba może stać przed rzeczownikiem, bo forma jest wtedy zawsze ta sama. +- Komunikaty `{n} {noun}` (np. w panelu Odszumianie AI) mają postać `{noun}: {n}`, a obie formy + rzeczownika (`photo waiting` / `photos waiting`) tłumaczą się tak samo. +- Font Inter dołączony do aplikacji zawiera wszystkie polskie litery i cudzysłowy; inny font + nie jest potrzebny. + +## Uzupełnienie (2026-10-15) + +- Przetłumaczono 40 komunikatów `pl-formats.json`, które były jeszcze po angielsku (ustawienia + efektów masek, szablony pokazu slajdów, import katalogu Lightroom, słowa kluczowe, osoby, + scalanie panoram, komunikat o nieoczekiwanym zakończeniu programu) i 2, w których zmieniono + tylko strzałkę. +- Poprawiono 25 komunikatów, w których liczba stała przed rzeczownikiem w dopełniaczu i dla + 2–4, 22–24… dawała błędną formę („Usuń 2 zdjęć”), oraz `{} failed`, gdzie `{}` jest nazwą + zadania, a nie liczbą. +- Daty: `{month} {year}` i `{weekday}, {day} {month} {year}` mają postać dzień.miesiąc.rok + zamiast „20 9 2026”. + +Katalog ma 2 159 zwykłych komunikatów i 291 szablonów formatów. Komunikaty, których nie ma w +katalogu, oraz techniczne błędy niższych warstw wyświetlają się po angielsku. + +## Weryfikacja + +Test `i18n::tests::polish_switches_formats_dates_and_reads_every_count` sprawdza przełączanie +z menu i kanału sterowania, zapamiętanie języka, znaczniki `pl-*`, daty, czas, liczby 0–111 +(w tym 2–4 i 22–25) w liczonych komunikatach, nazwy użytkownika pozostawione bez zmian oraz +polskie znaki w fontach interfejsu. Zgodność placeholderów sprawdza +`catalogs_agree_on_placeholders_and_report_gaps`. + +Postęp dodawania języków opisuje [localization.md](localization.md). diff --git a/docs/localization.md b/docs/localization.md index 5b4cbc15d..b878a5de2 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -2,11 +2,11 @@ The interface ships in the language it is written in (English) plus every language in the table in `crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional -Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`), Georgian (`ka`), Swedish (`sv`), Finnish (`fi`), Indonesian (`id`) and Vietnamese (`vi`). This file is the reference for **adding or maintaining a language**; the per-language notes +Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`), Italian (`it`), Georgian (`ka`), Swedish (`sv`), Finnish (`fi`), Indonesian (`id`), Vietnamese (`vi`) and Polish (`pl`). This file is the reference for **adding or maintaining a language**; the per-language notes are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md), [`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md), [`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md), -[`localization-cs.md`](localization-cs.md), [`localization-fi.md`](localization-fi.md), [`localization-ka.md`](localization-ka.md), [`localization-id.md`](localization-id.md) and [`localization-vi.md`](localization-vi.md). +[`localization-cs.md`](localization-cs.md), [`localization-fi.md`](localization-fi.md), [`localization-ka.md`](localization-ka.md), [`localization-id.md`](localization-id.md), [`localization-vi.md`](localization-vi.md) and [`localization-pl.md`](localization-pl.md). ## Adding a language diff --git a/docs/mcp.md b/docs/mcp.md index 31e069de9..4800c37d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,7 +170,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now | | `run_command {command, params?}` | Run any command by id | -| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), `mode: "move"` moves there (each original and its XMP sidecars are removed from the source only after the copy is verified and catalogued; duplicates and failures keep their sources; the result's `moved` / `kept` list what moved and what stayed, and why; undo leaves the moved files at the destination), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) | +| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), `mode: "move"` moves there (each original and its XMP sidecars are removed from the source only after the copy is verified and catalogued; duplicates and failures keep their sources; the result's `moved` / `kept` list what moved and what stayed, and why; undo leaves the moved files at the destination), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`) or `{date:%Y}/{date:%B}` (→ `2026/January`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) | | `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) | | `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were | | `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | diff --git a/docs/merge.md b/docs/merge.md index ce9c71beb..e2183583a 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -8,7 +8,8 @@ All algorithms are our own implementations from published papers (cited in the m - **App:** select the photos, then *Photo Merge ▸ HDR…* (`Ctrl+H`), *Panorama…* (`Ctrl+M`) or *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews - the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge + the merge at ≤ 1024 px and re-runs the preview when an option changes; as in Lightroom, a click on the preview + zooms in on that point (drag to pan, click again to fit), and it stays there while options change; *Merge* runs the full merge in the background (a row in the activity stack shows its progress, with ✕ to stop it). The result is written next to the first photo as `<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. *Photo Merge ▸ HDR for Each Bracket Set* merges many brackets in one go with the last HDR options: one HDR per stack among the selected photos, then per diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..d059494ce 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -44,7 +44,7 @@ cited here still exists. Formerly docs/parity.md. | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | -| S. Export (EXP) | 15 | 4 | 0 | 0 | 7/7 (100%) | 7/7 (100%) | +| S. Export (EXP) | 14 | 5 | 0 | 0 | 7/7 (100%) | 6/7 (86%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | -| Lightroom Classic extras | 40 | 25 | 21 | 8 | — | 24/26 (92%) | -| **Total** | 421 | 64 | 57 | 35 | 196/203 (97%) | 147/161 (91%) | +| Lightroom Classic extras | 41 | 25 | 21 | 8 | — | 24/26 (92%) | +| **Total** | 421 | 65 | 57 | 35 | 196/203 (97%) | 146/161 (91%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.6%** of 542 in-scope rows — P0 98.3% of 203 · P1 94.7% of 161 · P2 56.5% of 177. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 543 in-scope rows — P0 98.3% of 203 · P1 94.4% of 161 · P2 56.7% of 178. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -70,7 +70,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/album_picker.rs`, `crates/ui-egui/src/tests_album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | -| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | +| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests_drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) | | LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/it/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/it/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant Nikon 12-bit small RAW (D810 / D4S): packed Y0/Y1/Cb/Cr rows decode to linear RGB; header probes retain geometry without decoding pixels, and truncated strips are refused. Chroma gains are approximate. | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/it/corpus.rs`, `crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `crates/raw/src/dng.rs` (`decode_reduced`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs; a DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom HDR/panorama merges) develops a thumbnail or preview from the smallest copy that covers it (512 px of a 23836 × 7078 pano: 38 s → 1.1 s) | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | @@ -96,11 +96,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:<id>`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:<id>`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | -| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:library.holdView`, `cmd:library.refresh`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or sort | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:library.holdView`, `cmd:library.refresh`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; views and source counts with direct or nested relative-date rules expire on the session-clock minute; sidebar counts remain cached without building id lists; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit or clock expiry made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or sort, while newly matching photos enter at once | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | -| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/rating.rs` | one shared star row in the bottom bar, Info panel and filter bar (hover previews, ←/→ on a focused star) and one painter for badges (grid cells, filmstrip, Compare caption); menu rows with star text (Photo ▸ Set Rating, Select By Rating, the grid context menu) are separate menu items, unchanged; 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. A rating, flag or label that takes the active photo out of the view (filter bar, Picks, smart album) makes the photo after it active (the one before at the end), and Shift / Auto Advance doesn't step past it (#2312). Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | @@ -111,10 +111,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | -| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. | +| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. A capture time that doesn't fully read (cut short, or text after it) is left as it is and counted (`unreadable`); it can't anchor a set that moves other photos, and set alone repairs it. | | LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | -| LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | +| LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s, both limits adjustable in the dialog and as `burstGap` / `similarity`, #2275) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | | LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ✅ | `cmd:album.toggleTarget`, `cmd:album.clearQuick` | B in the grids | | LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `cmd:photo.delete`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; deleting a copy dissolves or renumbers the stack (#1493); no “Set Copy as Master” | @@ -129,17 +129,17 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | -| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. Photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo, each tile's caption shows its colour label, hover × removes | -| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions` | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | +| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions`, `crates/ui-egui/src/panels/detail.rs` (`info_overlay`) | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel); the star rating and flag show under the lines when the photo has them, and in the full-screen preview on macOS the overlay starts below the window controls (issue #2460) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; the plot's five tonal zones also drag to adjust their Basic control (LRC-DEV-HISTOGRAM-DRAG); it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; | ## D. Search & filter (FILT) @@ -184,7 +184,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) | | LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | | LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | -| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | +| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*`, `crates/pipeline/src/curve.rs` | the graph draws the renderer's own curve model; dragging the line moves the region under the pointer (one undo step) | | LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | | LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | | LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | | @@ -198,7 +198,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | | LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | -| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); a wheel drag holds the pointer as in Lightroom: hidden while dragging (the puck follows the mouse's travel; on macOS the pointer is locked in place), back where the button went down on release, and given back at once on Esc, a lost window focus or a wheel that goes away mid-drag; the browser can't move the pointer, so there it only shows again where the drag ended (issue #1899; tests `a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits`, `a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed`, `escape_or_losing_focus_gives_the_pointer_back_mid_drag`, `a_wheel_that_goes_away_mid_drag_gives_the_pointer_back`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | @@ -248,7 +248,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A, its own row in Keyboard Shortcuts: `crop.lockAspect` toggles it, sharing A with Visualize Spots by context), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required); turns by at most 10° (`STRAIGHTEN_MAX_DEGREES`) and only on at least 3 agreeing lines (`STRAIGHTEN_MIN_LINES`), and says so when it finds nothing (`auto_straighten_says_when_it_finds_nothing`). Measured on 68 photos straightened by hand in Lightroom Classic and 300 left as shot: it levels 14 of the 68 within 0.5° (1 more than 1° off) and turns 19 of the 300 by more than 1° (before the limits: 79, some by 20° or more). 🟡 because it still leaves most tilted photos alone: a soft sea horizon among wave crests, and verticals that converge in perspective, aren't used yet | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | @@ -321,7 +321,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; the preset applied to a photo stays marked on returning to it until the photo is edited further (issue #2392); resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | | LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | -| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); no hiding of groups | +| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); starred presets are listed in a Favorites section on top, as in the Profile browser (issue #1999, `crates/ui-egui/src/tests_preset_favorites.rs`); no hiding of groups | | LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | | LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | | LR-PRE-PREMIUM | Built-in presets (own) | P2 | ✅ | `crates/engine/src/presets.rs` | 41 own-authored presets in 10 groups (Color, Film, B&W incl. toners, Portrait, Landscape, Urban, Food, Seasons, Vintage, Style) | @@ -352,7 +352,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | +| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, click-to-zoom preview (drag pans; the zoom stays while options change), auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto (projected with Lanczos-3), boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | @@ -392,14 +392,14 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | | LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | -| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373) | +| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | +| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | @@ -429,7 +429,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-<time>` and reported, an unreadable one is not overwritten that session (`library.info` → `settingsWarnings`); quitting with unsaved changes retries, then asks | +| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; At launch: open the last library or ask (see LRC-LIB-CATALOG-SWITCH); a damaged settings file is kept as `.corrupt-<time>` and reported, an unreadable one is not overwritten that session (`library.info` → `settingsWarnings`); quitting with unsaved changes retries, then asks | | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings`, `cmd:view.appearance`, `cmd:view.theme` | Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | @@ -461,27 +461,27 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/ui-egui/src/progress.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | -| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | +| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (a drop-target card while they hover, then the import review); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | -| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/panels/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests_dialog_fit.rs` | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen; lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window | +| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/panels/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests_dialog_fit.rs`, `crates/ui-egui/src/tests_dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen; lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`) | | LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`), `crates/ui-egui/src/sidebar.rs` (`SidebarLayout`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date, Folders or Keywords header (or drive `ui.clickWidget` `sidebarSection:<albums\|local\|byDate\|folders\|keywords>`) to fold or unfold the section; a chevron before the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and `cmd:view.sidebarSection` `{section, collapsed}` folds and unfolds for agents; photos dropped on a folded Albums section have no target | | LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests_panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:<id>`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked; once made, Keywords is shown and opened down to it). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top | | LR-BEHAV-SIDEBAR-HIDE | Show and hide left-sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset`, `crates/ui-egui/src/sidebar.rs`, `crates/ui-egui/src/panels/left.rs` (`sections_menu`), `crates/ui-egui/src/menubar.rs`, `crates/ui-egui/src/tests_panels.rs` | View ▸ Sidebar Sections lists Albums, Local, By Date, Folders and Keywords in the sidebar's order, each checked while shown; unchecking one takes its header and rows off the sidebar, checking it puts it back where and how it was (still folded if it was). The same list opens with a right-click on any section header (with Hide This Section first; widgets `sidebarMenu:hide`, `sidebarMenu:section:<id>`, `sidebarMenu:reset`) and on the My Photos title (`sidebarTitle`), which is always there, so hidden sections can always come back. Reset Sidebar Sections (enabled once something changed) shows and opens every section in the usual order. `view.sidebarSection` `{section, show?, collapsed?}` flips shown / hidden when neither flag is given and refuses an unknown section or a flag that is not true / false. Hiding the section of what the grid shows (an album, a keyword filter) leaves the grid as it is; an album made while Albums is hidden or folded brings Albums back, open. Kept with the UI state (`sidebar`). My Photos itself can't be hidden | | LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before: <section>\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | -| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:<id>`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:<id>`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:<id>`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | +| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:<id>`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:<id>`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:<id>`, with View ▸ Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | -| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. No drop-target highlight while files are dragged over the window yet, and a drop imports without the review the menu opens | +| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. Files dragged over the window dim it and a card says what dropping will do; a drop of photos or folders opens the same import review as File ▸ Import | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/it/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/it/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests_canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Dialogs with one name field (New Album, New Smart Album, Save Metadata Preset, the renames) take the focus once when they open, so Tab and Shift+Tab reach their check box, Cancel and OK, and Return and Esc still confirm and cancel (`crates/ui-egui/src/tests_dialog_tab.rs`). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | -| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese, Thai and Georgian faces in WASM while loading the Simplified Chinese face from a separate font file. | +| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian`, `cmd:app.language.polish`, `cmd:app.language.czech`, `cmd:app.language.italian`, `cmd:app.language.swedish`, `cmd:app.language.finnish`, `cmd:app.language.indonesian`, `cmd:app.language.vietnamese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md`, `docs/localization-pl.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, French, Ukrainian, Polish, Czech, Italian, Georgian, Swedish, Finnish, Indonesian and Vietnamese UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese, Thai and Georgian faces in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | @@ -529,8 +529,8 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-EDIT-FIND | Find… | P0 | ✅ | `cmd:view.focusSearch`, `crates/ui-egui/src/panels/topbar.rs` | ⌘F focuses the search field | | MENU-VIEW-PHOTOGRID | Photo Grid | P0 | ✅ | `cmd:view.photoGrid` | | | MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | | -| MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | | -| MENU-VIEW-EDIT | Edit | P0 | ✅ | `cmd:panel.edit` | | +| MENU-VIEW-DETAIL | Detail View | P0 | ✅ | `cmd:view.detail` | | +| MENU-VIEW-EDIT | Edit Panel | P0 | ✅ | `cmd:panel.edit` | | | MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ✅ | `cmd:view.fullScreenPreview` | | | MENU-VIEW-LIGHTSOUT | Lights Out | P2 | ✅ | `cmd:view.lightsOut` | everything but the photo veiled; panels stay put | | MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ✅ | `cmd:view.enterFullScreen` | | @@ -545,12 +545,12 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-SHOWORIGINAL | Show Original | P0 | ✅ | `cmd:view.showOriginal` | | | MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | ✅ | `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | | | MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | | -| MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | `cmd:view.clipping` | | +| MENU-VIEW-CLIPPING | Clipping | P0 | ✅ | `cmd:view.clipping` | | | MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor` | colour cycles through the panel's swatches (no params) or takes `color` / `opacity` | | MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | ✅ | `cmd:library.browse` (`subfolders`) | toggle in the folder header; imports of folders are recursive | | MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle` | Random + Reshuffle; no colour-label key | | MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | | -| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | ✅ | `cmd:view.photoCounts` | View ▸ Show Photo Counts toggles the left panel's counts | +| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | ✅ | `cmd:view.photoCounts` | View ▸ Photo Counts toggles the left panel's counts | | MENU-VIEW-SIDEBARSECTIONS | Sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset` | View ▸ Sidebar Sections ▸ a checkable item per left-sidebar section, then Reset Sidebar Sections (see LR-BEHAV-SIDEBAR-HIDE) | | MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | | MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | off for a Local-only selection, with an Add to My Photos First hint item (issue #684) | @@ -604,7 +604,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-EDIT | Edit — E | P0 | ✅ | `cmd:panel.edit` | | | KEY-FULLSCREEN | Full-screen preview — F | P1 | ✅ | `cmd:view.fullScreenPreview` | | | KEY-LIGHTSOUT | Lights Out — Shift+L (Classic's L is Linear Gradient here) | P2 | ✅ | `cmd:view.lightsOut` | On → Dim (80 %) → Off; Esc turns the lights on | -| KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.gridToggle`, `cmd:view.photoGrid` | G toggles Photo Grid ↔ Square Grid | +| KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.gridToggle`, `cmd:view.photoGrid` | G toggles Photo Grid ↔ Square Grid; both View menu rows show its live shortcut (including remapping/unbinding), with direct grid bindings taking priority; menu clicks still choose the named grid (#2500) | | KEY-INFO | Info — I | P0 | ✅ | `cmd:panel.info` | | | KEY-KEYWORDS | Keywords — K | P0 | ✅ | `cmd:panel.keywords` | | | KEY-CLIPBOARD | Copy / paste edit settings — ⌘C / ⌘V | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste`, `crates/ui-egui/src/shortcuts.rs` (`matches`) | ⌘X has nothing to cut outside text fields; on Windows and Linux, where the windowing layer sends Ctrl+C / Ctrl+V as clipboard events rather than key presses, those run the shortcuts too (⇧Insert pastes plainly); there Ctrl+V only arrives while the system clipboard holds text (egui-winit drops it otherwise), so with an empty clipboard Paste Settings needs the menu | @@ -691,7 +691,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import | +| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 or `{date:%Y}/{date:%B}` → 2026/January, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import | | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | | LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; no people view; picking one in Photo Grid switches to Square Grid) | @@ -699,19 +699,19 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does. Escape → Detail without losing edits; pins are catalog-local; missing/deleted photos refused and deleted references no longer drawn (Windows regression/native smoke verified) | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `cmd:file.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `crates/ui-egui/src/lightroom_destination.rs`, `crates/ui-egui/src/tests_lightroom_destination.rs`, `docs/lightroom-catalog-import.md` | before the import starts, File ▸ Import Lightroom Catalog… asks where the catalog goes: merge into the open library (named, with its photo count; preselected when it is empty) or create a new library (named after the catalog file, next to the open library, both editable), which is made and opened following File ▸ New Library…'s rules before the import; Cancel does nothing; Settings ▸ General ▸ "When importing a Lightroom catalog" (Ask where to import it / Always merge into the open library) or the window's "Don't ask again" turns it off; `file.importLightroom` takes `into` (`open` / `new`), `name`, `location` to choose without the window; a session in memory merges without asking (issue #2672); read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | -| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | +| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs`, `crates/ui-egui/src/panels/library_problem.rs` (`launch_plan`, `show_chooser`), `crates/ui-egui/src/tests_launch_library.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Remove from List ▸ one entry, or `app.recentLibraries {remove}`, which forgets only the path; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key; Settings ▸ General ▸ At launch: Open the last library (default) or Ask me, a window listing the recent libraries (name, folder, when last opened; missing, unreadable or no-longer-library ones greyed out with the reason, checked read-only — nothing is locked or written, so a library another program has open is reported, with who has it, when Open is pressed; the last one selected; Open, Open Another…, New Library…, Remove from List, Quit; ↑ ↓ Return Esc), also shown once when Alt/Option is held in the first 2 s after the window appears (macOS reports an Option key held since before launch only once it changes); `--library` and `LIGHTCRAFT_LIBRARY` come first and `lightcraft-cli` never asks (issue #1290) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | -| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | +| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (an album the filter held is replaced by the album you show; always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:<keyword>`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:<keyword>`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields. In the Keyword List a keyword's empty box carries a dot when the selected photos have a keyword inside it (its tooltip names them), so a folded keyword shows that something in it is applied | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people @@ -723,7 +723,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | | LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.previous`, `cmd:history.next`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history; clicking a step loads it without adding one (current step highlighted, later steps greyed and returnable until a new edit truncates them; one undo step; cursor saved in the catalog, old catalogs open at the latest step) | -| LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | `cmd:view.softProof`, `crates/pipeline/src/output.rs` (`Proof`), `crates/ui-egui/src/panels/edit.rs` (`soft_proofing`) | S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings (the display's gamut is the monitor profile's, `cmd:app.displayProfile`, else sRGB), Create Proof Copy (a named virtual copy). printer / paper ICC profiles can be loaded as the proof profile (size-capped, the last few kept), with rendering intent (Perceptual / Relative Colorimetric), Simulate Paper & Ink and a device-gamut warning (`crates/codecs/src/proof.rs`, `crates/engine/src/proof.rs`) | +| LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | `cmd:view.softProof`, `crates/pipeline/src/output.rs` (`Proof`), `crates/ui-egui/src/panels/edit.rs` (`soft_proofing`) | S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings (the display's gamut is the monitor profile's, `cmd:app.displayProfile`, else sRGB), Create Proof Copy (a named virtual copy). printer / paper ICC profiles can be loaded as the proof profile (size-capped, the last few kept), with rendering intent (Perceptual / Relative Colorimetric), Simulate Paper & Ink and a device-gamut warning (`crates/codecs/src/proof.rs`, `crates/engine/src/proof.rs`); on the GPU for output-space proofs (destination and display gamut warnings included; equivalence-tested), an output-device profile's 3D table on the CPU | | LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | ✅ | `cmd:develop.sync`, `cmd:develop.autoSync`, `cmd:develop.pastePrevious` | Edit ▸ Sync Settings / Auto Sync (⌥⇧⌘A): only the changed settings carry over, one undo step, slider drags sync on release, spots / red eye stay per photo; Edit panel banner | | LRC-DEV-MATCHEXP | Match total exposures | P2 | ✅ | `cmd:develop.matchExposure` | Photo ▸ Match Total Exposures: the selected photos' Exposure set so shutter × ISO ÷ aperture² plus the slider matches the active photo's | | LRC-DEV-CALIB | Calibration panel | P1 | ✅ | `ctl:calibration.*` | | @@ -733,6 +733,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/panels/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; applied as they change, saved with the app settings; `view.viewOptions {info1, info2}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), "show briefly when the photo changes", the loading-message and video-frame options | | LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | +| LRC-DEV-HISTOGRAM-DRAG | Drag on the histogram to adjust tone regions | P2 | ✅ | `crates/ui-egui/src/panels/histogram.rs`, `crates/ui-egui/src/panels/edit.rs`, `crates/develop/src/controls.rs`, `crates/ui-egui/src/tests_histogram_drag.rs`, `cmd:develop.set`, `cmd:develop.beginInteraction`, `cmd:develop.endInteraction`, `ctl:light.blacks`, `ctl:light.shadows`, `ctl:light.exposure`, `ctl:light.highlights`, `ctl:light.whites` | five zones across the plot's width, each moving the Basic control it names: Blacks 0.00–0.15, Shadows 0.15–0.35, Exposure 0.35–0.65, Highlights 0.65–0.85, Whites 0.85–1.00; every step is a `develop.set` between `develop.beginInteraction` and `develop.endInteraction` (the sliders' own path), so the draft render follows the drag live and the whole drag lands as one undo step; the zone the press landed in stays locked for the drag, so dragging across the others changes nothing; sideways travel of the plot's width sweeps that control's whole range (Shift a tenth, as on the sliders), clamped to its spec; hovering a zone tints it and shows its value in the strip under the plot ("Exposure: +0.45 EV"), with the sideways-resize cursor; a double-click resets that zone's control to its default in one undo step; the strip under the plot (readout / EXIF) and the clipping buttons in the plot's top corners are not zones | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | | LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | | | LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | | @@ -752,7 +753,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-TEMPLATES | Slideshow templates | P2 | 🟡 | `cmd:slideshow.templates.list`, `cmd:slideshow.templates.apply`, `cmd:slideshow.templates.save`, `cmd:slideshow.templates.update`, `cmd:slideshow.templates.rename`, `cmd:slideshow.templates.delete`, `cmd:slideshow.templates.import`, `cmd:slideshow.templates.export`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_template.rs`, `path:crates/ui-egui/src/panels/slide_templates.rs` | Template Browser at the top of Settings ▸ Slideshow: a template is the Playback (not "prepare previews"), Layout / Options / Backdrop, Overlays and Titles settings as one versioned JSON document. Five built-ins of our own (Plain Black, Gallery Wall, Edge to Edge, Cinema Wide, Shooting Data; no Lightroom names or designs, no image or font files) and the user's own, saved as files in `<settings>/slideshow-templates` (machine-wide, like Classic's): save current settings, Update with Current Settings, rename, delete (confirmed), import / export `.json`. Click applies (one step, saved with the library); pointing at one previews it on the selected photo (or the intro / ending) in the live preview without changing anything (`slideshow.preview {template}`). No undo for library preferences: the settings a template replaced stay as "Previous settings" for the session (applying it again goes forward). Every section is read through its settings' own validation (out-of-range values clamped, wrong types or unknown keys refused), files are capped (256 KB, 1000 listed), a newer format version is refused with a message, a bad file is listed as skipped, names are sanitised into file names that can't leave the folder, a missing plate / watermark image or background photo applies with a warning. Missing: the Slideshow module's left-panel browser (it lives in Settings until the module view exists), template folders (New Folder, moving templates between folders), saving a slideshow with a collection, a preview thumbnail inside the browser itself | | LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists | | LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | -| LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) | +| LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK and Thai, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, horizontal Thai mark positioning, Arabic joining and right-to-left reordering (no Arabic export fallback; Arabic / Hebrew remain missing-glyph boxes with the bundled faces) | | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | | LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow). Slideshow export (LRC-SS-EXPORT) includes them: a PDF page / JPEG each, and in video they hold and fade like slides | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | @@ -760,7 +761,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-EXPORT | Slideshow export | P2 | 🟡 | `cmd:slideshow.export`, `cmd:slideshow.exportStatus`, `cmd:dialog.slideshowExport`, `cmd:app.slideshowExport`, `path:crates/engine/src/slideshow_export.rs`, `path:crates/codecs/src/video.rs`, `path:crates/ui-egui/src/slideshow_export.rs` | View ▸ Export Slideshow… (and Export PDF / JPEG / Video in Settings ▸ Slideshow): the photos in view as their composed slides (layout, overlays) between the intro and ending screens that are on, in view order. **PDF**: one page per slide at 1024 / 1920 / 3840 px (any 64–8192 by command; page = slide pixels in points, aspect from the aspect preview, "screen" = 16:9), JPEG quality, each page carrying the slide duration and a fade transition for presentation mode, "Automatically show full screen" (`/PageMode /FullScreen`). **JPEG**: `<name>-001.jpg`… in a new folder. **Video**: AV1 (rav1e, fast preset) in MP4 or WebM written by our own muxers, 720p / 1080p (64–2160 by command), 24 / 30 fps (25 / 60 by command); each slide held for its duration (one long sample: variable frame rate) and fades rendered as blended frames (crossfade or through the fade colour); at most 2 hours. Runs in the background with a row in the activity stack (frames or slides done, ✕ cancels); files are written as `.part` and renamed when complete, so a cancelled or failed export leaves nothing; a photo that can't be rendered is skipped with a warning. Missing: H.264 (Classic's codec; patents, no pure-Rust encoder), audio / music in the video (LRC-SS-MUSIC), random order and pan & zoom in exports, the PDF's per-page fades follow the viewer (Acrobat), export presets, Classic's export dialog sizes beyond these, the web build (no export) | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ✅ | `cmd:dialog.print`, `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/ui-egui/src/print_view.rs` | File → Print… (⌘P): Single Image, Contact Sheet, Picture Package and Custom Package, one page preview each; also `print.*` commands for the CLI, control channel and MCP; the older File → Contact Sheet PDF stays | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ✅ | `cmd:print.set`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | Zoom to Fill Frame, Rotate to Fit, Photo Border (width, colour; package styles) and Inner Stroke (width in points, colour) in the dialog, drawn in the preview and in the PDF; pixel-tested | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19, the Japanese photo and postcard sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm, by their customary names) and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | | LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides | @@ -798,26 +799,48 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged | +| 2026-10-24 | patch | Integration batch 43: Integrated main 98702b5c, preserving the new fail-closed GPS metadata check for Original export. Watermark geometry/orientation and HDR JPEG size-limit fixes remain in their shared helpers; no feature-count changes. | +| 2026-10-23 | patch | Export integration: Integrated main 5ca49b3e, retaining Width & Height resizing, export format/bit-depth validation and batch panic cancellation alongside the existing watermark and HDR JPEG size-limit fixes. No feature-count changes. | +| 2026-10-23 | minor | LRC-LIB-CATALOG-SWITCH, LR-PREF-GENERAL: Settings ▸ General ▸ At launch (Open the last library / Ask me) with a launch window listing the recent libraries, Alt/Option at launch asks once, File ▸ Open Recent Library ▸ Remove from List and `app.recentLibraries {remove}` (issue #1290); photos dropped while no library is open wait for the one opened (presets are refused then); status and counts unchanged (✅) | +| 2026-10-23 | minor | LRC-LIB-CATALOG-IMPORT: Import Lightroom Catalog… (and Open Library's offer for a Lightroom catalog) asks whether the catalog merges into the open library or goes into a new library made next to it; Settings ▸ General or "Don't ask again" switches the question off; `file.importLightroom` takes `into` / `name` / `location` (#2672); status unchanged (🟡) | +| 2026-10-22 | minor | LR-VIEW-INFOOVERLAY: the info overlay shows the star rating and flag, and in the full-screen preview on macOS it starts below the window controls (issue #2460); status unchanged | +| 2026-10-22 | patch | KEY-GRID: both grid rows in View show the cycle shortcut and follow keymap changes; direct bindings and native accelerator ownership are preserved (#2500); status and counts unchanged | +| 2026-10-22 | minor | LRC-PRINT-LAYOUT: the Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) are selectable in the page setup and Print dialog and print to PDF at their metric sizes | +| 2026-10-22 | minor | #2052 (partial): shared export text adds only Thai faces after Inter and the existing CJK chain. Thai horizontal mark positioning remains incomplete; Arabic fallback is deferred until joining and bidi reordering exist. LR-EXP-WATERMARK reassessed as partial for these layout and font limits; UI fallback unchanged | +| 2026-10-24 | patch | LR-LIB-SMARTALBUM: relative-date view and source-count caches expire on the session-clock minute, including nested smart albums; held views retain expired photos until refresh and admit new matches; status and feature counts unchanged | | 2026-10-22 | patch | LR-META-XMP: namespace URI determines ownership; foreign namespaces using recognized prefixes survive saves and cannot supply native settings or ratings; status and counts unchanged | | 2026-10-22 | patch | LR-EXP-PSD, LR-META-EXIF-FULL: PSD/PSB imports and image resources 1058/1060 now supply EXIF and XMP to metadata readers and All Metadata; Original with Remove Location Info refuses detected PSD/PSB GPS; statuses and feature counts unchanged | +| 2026-10-21 | patch | LR-LIB-CAPTURETIME: a capture time that doesn't fully read is left as it is and counted (`unreadable`) instead of shifted into a wrong one; it can't anchor a set that moves other photos; status unchanged | | 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | -| 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged | | 2026-10-21 | minor | KEYC-PANELS: F8 shows / hides the right panel (`view.rightPanel`), with F6 / F7 already bound; ⬜ → 🟡 | +| 2026-10-20 | patch | Graphic watermark inset regression: Oversized graphics fit the space remaining after the applicable anchor insets, with one aspect-preserving scale before quarter-turn rotation. Centered axes continue to ignore insets. Pixel-bound regressions cover edge/corner anchors, centered placement and all four rotations. Main d999e382 is integrated; HDR size-limit and EXIF-orientation fixes are unchanged. | +| 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-20 | minor | LR-PRE-PANEL: the preset applied to a photo is marked again (with its Amount slider) on returning to the photo, until the photo is edited further (#2392); status unchanged (✅) | | 2026-10-20 | minor | LR-MASK-OVERLAY: creating, picking, duplicating or adding to a mask shows the overlay a mask slider hid (#1800); status unchanged (✅) | | 2026-10-20 | patch | LRC-LIB-COMPARE, LR-VIEW-SURVEY: in Compare and Survey the Info panel's stars and colour labels and the Set Color Label menu act on the active photo only (they labelled the whole selection), and the panes show the colour label (#2398); statuses unchanged | | 2026-10-20 | patch | LR-VIEW-ZOOM: the 20 ms rule clears a slow-drag verdict only on a drag timed at full size; a drag that drafts small leaves it unchanged, as the code does; status unchanged | +| 2026-10-19 | minor | LR-CROP-ASPECT: Lock Aspect Ratio is its own row in Help ▸ Keyboard Shortcuts (`crop.lockAspect`, default A, sharing A with Visualize Spots as a contextual pair); statuses unchanged (✅) (`tests_keymap` `lock_aspect_ratio_is_a_listed_row_on_a`, `a_is_the_aspect_lock_under_crop_and_visualize_spots_elsewhere`) | +| 2026-10-19 | patch | LR-LIB-RATING: a rating, flag or label that takes the active photo out of the view makes the photo after it active instead of leaving an unseen photo active (→ then went to the view's first photo), and Shift / Auto Advance doesn't step past it (#2312); status unchanged (✅) | +| 2026-10-19 | patch | LR-LIB-CULL: Assisted Culling's burst gap and similarity are adjustable (dialog, `photo.analyze` `burstGap` / `similarity`; #2275). Statuses and feature counts unchanged. | | 2026-10-19 | patch | LR-CROP-RECT: on a straightened photo a moved crop slides along the tilted edges, so a box that fills the photo's height reaches its sides (#2299); status unchanged | | 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged | +| 2026-10-18 | patch | Export regression integration (maintenance): Graphic watermarks honour EXIF rotation/mirroring through the shared `read_graphic` path and keep their aspect ratio when fitted to the output. HDR JPEG `limitKb` includes the gain map and metadata. Refreshed onto main fb7ac08b, retaining upstream export bounds, colour metadata, JPEG encoding and opaque RGB/real-alpha handling (#1691); no feature-count or readiness estimate changes. | | 2026-10-18 | patch | LR-META-XMP: Lightroom's profile and version bookkeeping (`CompatibleVersion`, profile digests, lens profile identity, `CropConstrainToUnitSquare`, default-strength lens profile scales) is no longer reported as unimported XMP; status unchanged (✅) | +| 2026-10-17 | minor | MENU-VIEW-DETAIL, MENU-VIEW-EDIT, MENU-VIEW-CLIPPING, MENU-VIEW-MASKOVERLAY, MENU-VIEW-PHOTOCOUNT, MENU-VIEW-BEFOREAFTER, LR-VIEW-FOCUSPOINT: menu labels renamed: switches are named for what they show (Photo Counts, Focus Point, Clipping, Mask Overlay, All Mask Overlays, Mask Pins, Layout Image), View ▸ Detail is Detail View, Window ▸ Edit is Edit Panel, Compare Before and After is Before/After Side by Side, Clear Preview Cache is Discard Preview Cache; shortcuts and commands unchanged; statuses unchanged | +| 2026-10-17 | minor | LR-IMP-DRAGDROP: files dragged over the window show what dropping will do, and dropped photos and folders open the import review (Settings ▸ Import can import them at once); a drop while a dialog is open or an import is running is refused; a finished scan no longer replaces an open dialog; the web build tells the person when a dropped file can't be read or imported. LR-BEHAV-EMPTY-STATES, LR-BEHAV-DRAGDROP: text updated; status unchanged (✅) | +| 2026-10-16 | patch | LR-BEHAV-BG-TASKS: progress bars and spinners come from one component (`progress.rs`): the activity stack, model downloads, denoise and the face scan share it; busy buttons and a themed thumbnail placeholder added; status unchanged | +| 2026-10-15 | patch | LR-BEHAV-LOCALIZE: cites `docs/localization-pl.md` (Polish format messages finished, counts right for every number); status unchanged (🟡) | | 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | | 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; its result is committed through the command wrapper (XMP sidecars auto-written as before) and an older reload finishing late no longer overwrites a newer one; status unchanged (✅) | | 2026-10-19 | patch | LR-LIB-STACK: the filmstrip's stack badge expands / collapses the stack like the grid's, and a badge click works on a stack that isn't selected (it is selected first). Statuses and feature counts unchanged. | +| 2026-10-14 | minor | LR-VIEW-ZOOM: Visualize Spots, Point Color range and Visualize HDR render zoom windows; LRC-DEV-SOFTPROOF: output-space proofs on the GPU | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | | 2026-10-15 | minor | LR-IMP-FORMATS: a Hasselblad FFF develops with the camera's colour matrix from the original 3FR header it embeds (tag `0xC51B`) instead of the neutral matrix; status unchanged (🟡) | +| 2026-10-13 | minor | LRC-LIB-KEYWORDS: the Keyword List marks a keyword that has an applied keyword inside it (a dot in its box); status unchanged | | 2026-10-12 | minor | LR-IMP-FORMATS: folder discovery includes SR2/SRF; MCP and CLI reuse the engine extension list. DSC-F828 remains preview-only; decoder support and row status unchanged | | 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); status unchanged (🟡) | | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | @@ -932,3 +955,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-22 | minor | MENU-BAR: on macOS the native menu gives a key back to the window while a text field has focus or a shortcut is being recorded (muda's `set_accelerator(None)` does nothing there, so the menu item is now swapped for one without a key, and the key is put back on that item afterwards); status unchanged (✅) | | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | +| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | diff --git a/docs/releasing.md b/docs/releasing.md index 914a7ad84..1704492d1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -66,7 +66,7 @@ the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a Local macOS, Linux and Windows packaging scripts automatically use the sibling `../craft-fonts` checkout. If it is absent, they clone it at revision -`3d9ab3a132ed0eff457e424226a435a901da30d3` (keep the pins in `packaging/fonts.sh` and +`b076417b5880bd4154bd22f00695b1d28c6bea83` (keep the pins in `packaging/fonts.sh` and `packaging/windows/fonts.ps1` in sync with the release workflow). Existing checkouts are left untouched. Set `CRAFT_FONTS_DIR` to use another checkout; relative paths resolve from the workspace root. The script requires the Chinese and Japanese diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0a7091a05..d72c165c4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -158,7 +158,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). | Discount | Mainstream | Essentials | Evidence | |---|---:|---:|---| | Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714) | -| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer, fixed in 0.9.1); tests run in CI on FreeBSD only (gap 9) | +| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer, fixed in 0.9.1); tests run in CI on Linux x64 and FreeBSD only (gap 9) | | File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | | **Product** | **×0.804** | **×0.830** | | @@ -300,6 +300,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | patch | Stability note: tests run in CI on Linux x64 (every PR) as well as FreeBSD; macOS and Windows remain (gap 9) | | 2026-10-19 | minor | Localization ~55% → ~64% (mean of the 12 key languages): Indonesian (`id`) catalog, every message but What's New; full target 61.2% → 61.5% (still ~61%) | | 2026-10-19 | patch | Platforms: the Windows installer is no longer listed as unverified (the 0.9.1 x64 MSI is tested on Windows 11; a confirmation after uninstalling from Settings → Apps, #892, remains); #790 noted as fixed in 0.9.1; percentages and hours unchanged | | 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7a746fdf3..16665f3d4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -27,7 +27,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla | Crop, Upright, transform handles | handles, aspect lock, angle, overlays (O / ⇧O), guided Upright | ✅ all of them; overlay cycle on ⇧O | 85% | crop opacity of cut-out area (15.5) | | Masking tools | brush (A / B, flow, density, auto mask, pressure), linear / radial, range masks, AI masks, pins | brush, linear, radial, colour / luminance range, add / subtract / intersect / invert, masks panel, pins | 60% | brush live preview (#517 / #518), no pen pressure, no depth range, AI masks, Feather / Edge controls (15.5) | | Remove / heal | content-aware remove, heal, clone, visualize spots | heal, clone, auto source, visualize spots, red / pet eye | 65% | content-aware (patch synthesis) remove, generative remove, detect distractions | -| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | diagnostic overlays (Visualize Spots, Point Color range, HDR range) render the whole frame instead of a zoom window; everything else, mask overlays and soft proofing included, is sharp at 1:1–8:1 | +| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like; every view, mask and diagnostic overlays (Visualize Spots, Point Color range, HDR range) and soft proofing included, is sharp at 1:1–8:1 | 60% | zoom stops at 800 % (Adobe's reference goes to 11:1) | | Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys | | Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer | | Context menus | everywhere | 31 sites (grid, filmstrip, albums, folders, keywords, masks, presets, text fields…), all through `widgets::context_menu` (`crates/ui-egui/src/widgets.rs`): a right-click elsewhere while a menu is open closes it and opens the menu there, also on the same widget (issue #1877: that one flashed at the new spot and closed, as egui counts the opening click as a click while open) | 70% | unaudited against Lightroom's menus item by item | @@ -151,6 +151,7 @@ establish OS keyboard delivery or cross-platform acceptance. | 2026-10-15 | patch | Context menus: a right-click elsewhere on the same widget while its menu is open moves the menu to the pointer; before it flashed at the new spot and closed (issue #1877); every site uses `widgets::context_menu`; 70% unchanged | | 2026-10-23 | patch | KEYC-RATING: ⌘L / Ctrl+L (Library ▸ Enable Filters) switches the filter off and on, restoring it as it was | | 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); ⌘L is the filter-bar key left | +| 2026-10-14 | minor | Zoom row: diagnostic overlays render zoom windows too | | 2026-10-14 | patch | Shortcuts: Visualize HDR loses its default ⇧H, which cycles the Tool Overlay (issue #224); both PRs had claimed it | | 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h | | 2026-10-13 | minor | Shortcuts: Tool Overlay modes for spot and mask pins on ⇧H (H stays Remove; issue #224), listed with the deliberate differences; dropped from the missing list | diff --git a/docs/user/guides/edit-a-photo.md b/docs/user/guides/edit-a-photo.md index 366bf5212..af268731a 100644 --- a/docs/user/guides/edit-a-photo.md +++ b/docs/user/guides/edit-a-photo.md @@ -9,12 +9,12 @@ Double-click a photo in the grid, then press `E` to open the **Edit** panel. Dra ## Steps -1. In **Photo Grid** or **Square Grid**, double-click the photo. It opens in the **Detail** view. -2. Press `E`, or choose **Window** > **Edit**, to open the **Edit** panel. +1. In **Photo Grid** or **Square Grid**, double-click the photo. It opens in the **Detail View**. +2. Press `E`, or choose **Window** > **Edit Panel**, to open the **Edit** panel. 3. Click **Auto** for a first pass, or skip this step and start with the sliders. 4. Open the **Light** section and set **Exposure**, **Contrast**, **Highlights**, **Shadows**, **Whites** and **Blacks**. 5. Open the **Color** section to change **Vibrance** and **Saturation**. Open **Effects** for **Texture**, **Clarity** and **Dehaze**. The panel also has **Detail** and **Optics** sections. -6. Check your work against the original. Hold `\` to peek at the original. Press `Y` (**View** > **Compare Before and After**) to see before and after side by side. +6. Check your work against the original. Hold `\` to peek at the original. Press `Y` (**View** > **Before/After Side by Side**) to see before and after side by side. 7. To go back a step, choose **Edit** > **Undo**. To drop all edits on the photo, click **Reset** at the top of the **Edit** panel. ## Black and white diff --git a/docs/web.md b/docs/web.md index 98d0dd91c..bce143a62 100644 --- a/docs/web.md +++ b/docs/web.md @@ -158,7 +158,14 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not `write` service hands each file to the browser as a download instead of writing it to disk. - **Automation.** `await lightcraft.command("library.info", "{}")` runs any engine or UI command by id on the next frame and resolves to the JSON result (`web.stats` reports storage, workers - and the render queue). This is how the headless-Chrome checks drive the page. + and the render queue). The params are a JSON object; empty text and JSON `null` mean `{}` + (no params: a command that takes photo ids then acts on the selection). Any other text that + isn't an object rejects the promise and runs nothing, as the control channel and MCP do, so a + typo can't run a command on the selection. This is how the headless-Chrome checks drive the page. +- **The page title names the open photo**: `<file name> — LightCraft`, with ` •` after the name while + changes are not yet written to browser storage (`LightcraftApp::page_title`). eframe's web runner + ignores the window title the desktop app sets, and a host that embeds the page reads this title to + tell which photo a drop opened and whether it is saved (issue #2397). ## Not yet diff --git a/docs/whats-new.md b/docs/whats-new.md index c09dddc09..c6c259200 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,24 @@ # What's new in LightCraft +## 2026-10-24 + +### Relative-date filters and smart albums follow the clock +- A current view or source count using “in the last” rules now refreshes when the session clock moves into a new + minute, even when no catalog edit occurred. This fixes stale photos remaining in the grid and stale counts for + direct or nested smart albums. Held views keep photos that expired until Refresh View while admitting photos that + newly match; expiry itself does not create an undo step. + +### The histogram's tonal zones adjust the Basic tones +- The histogram is a control surface again, as in Lightroom Classic: hovering one of its five zones (Blacks, Shadows, + Exposure, Highlights, Whites) tints it and shows its name and value under the plot ("Exposure: +0.45 EV"). Dragging one + sideways moves that control — the zone you pressed stays locked while the pointer crosses the others, Shift drags a + tenth as far, and the whole drag is one undo step. Double-clicking a zone puts its control back to its default. + +### Drag the parametric Tone Curve +- In the Tone Curve's parametric mode the graph is now drawn from the renderer's own curve, so it always matches the + photo, and the line can be dragged: pressing it picks the region under the pointer (Highlights, Lights, Darks or + Shadows) and moving up or down changes that slider, as one undo step. Hovering the line names the region first. + ## 2026-10-23 ### Tab leaves the name field in New Album, New Smart Album and the name prompts @@ -31,6 +50,38 @@ stay available in All Metadata; PSD edit copies keep their capture date. Original exports with Remove Location Info enabled are refused when a PSD or PSB embeds GPS, since LightCraft cannot strip metadata from a byte-for-byte original copy. +### Choose where a Lightroom catalog goes +- File ▸ Import Lightroom Catalog… now asks first: merge the catalog's photos, collections and edits into the + library open now, or create a new library for them, named after the catalog and placed next to the open library + (both can be changed). LightCraft opens the new library, then imports the catalog into it. Cancel does nothing. + Open Library… on a Lightroom catalog goes through the same choice. +- To import as before without the question, choose Settings ▸ General ▸ When importing a Lightroom catalog ▸ + Always merge into the open library, or tick Don't ask again in the window. Ask where to import it brings the + question back. + + +### Choose which library opens at launch +- Settings ▸ General ▸ **At launch**: *Open the last library* (as before) or *Ask me*. With *Ask me*, LightCraft + starts with a window listing your recent libraries: each one's name, the folder it is in and when you last opened + it. One that is gone (moved, renamed, on a disconnected drive), can't be read or no longer holds a library is + greyed out with the reason. Each one is checked in the background, showing *Checking…* until it answers (and that + the drive isn't answering after 5 s), so the list never waits on a network share that is offline or a disk that + has to wake up; File ▸ Open Recent Library uses the same answers (it greys out only libraries that are gone). Making the list only looks: it never locks or writes anything in your libraries, so one + that another program has open is reported, with who has it, when you press **Open**, and the window stays. The + last library is selected; **Open** (or Return, or a double-click) opens it, + **Open Another…** asks for a folder, **New Library…** makes one, **Quit** (or Esc) quits. ↑ and ↓ move through + the list. +- Holding Alt (Option on a Mac) as the window appears shows the same list once, over the library that opened: open + another one from it, or press Esc to keep working where you were. On a Mac, an Option key held from before + LightCraft starts is only noticed once it changes, so press it as the window appears. +- `--library` and `LIGHTCRAFT_LIBRARY` still decide the library when given, and `lightcraft-cli` never asks. +- File ▸ Open Recent Library ▸ **Remove from List** forgets one library, and the launch window's **Remove from + List** does the same. Only the entry goes: the library and its photos stay where they are. Agents use + `app.recentLibraries {remove: path}`; its list now also says when each library was last opened (`lastOpened`). +- Photos dropped on the window while the list (or the window of a library that couldn't open) is up wait for the + library you open and are imported into it, as files given on the command line are; presets dropped then are + refused with a note, since they belong to a library. + ## 2026-10-22 ### Reliability @@ -46,8 +97,66 @@ key could still run the menu command instead of reaching the field or the editor. The keys come back on the menu items when the field loses focus or recording ends; the menu's order, checkmarks and enabled state are unchanged. +### Find the grid shortcut in View +- Photo Grid and Square Grid now show the grid-cycle shortcut (G by default) in the View menu. The hints follow + shortcut changes; clicking a menu row still selects that grid (issue #2500). + + ## 2026-10-21 +### Settings window +- Settings is one size on every tab and stays where it is as you go through them; before, each tab made its own + size and the tabs moved under the pointer. The tabs stay in place while a long page scrolls (in a very short + LightCraft window, where the page has too little room to scroll by itself, the tabs and the page scroll together), a + page that goes on below shows a scroll handle without the pointer on it, and each page remembers where it was + scrolled to, also when Settings is closed and opened again. +- Nothing in Settings runs off the side any more. A row with more choices than fit (the languages on General) wraps + onto further lines and a long label wraps in its column, where both used to widen the window: in a small + LightCraft window the labels and the Close button of General ended up off screen. +- The Slideshow tab is one column like the other tabs, with the slide preview first, instead of a column beside the + preview that made Settings much wider on that tab. The preview stays above the page while it scrolls, so pointing + at a template further down still shows it there (in a very short window it scrolls with the page instead). Pointing + at a template no longer pushes its rows out from under the pointer, and the Export buttons wrap. +- A long folder (General ▸ Library ▸ Location, the folder Auto Import watches, the smart previews folder, a monitor + profile's file) is shortened to its last folders ("…/Pictures/Library") on one line; rest the pointer on it for + the whole path. +### One star rating control +- The stars in the bottom bar, the Info panel and the filter bar are now one shared control, and the badges on grid + cells, filmstrip thumbnails and the Compare caption use the same drawing. Hovering a star in the filter bar now + previews the rating like the others do, and with a star focused (Tab), ← and → lower and raise the rating. +### Progress looks the same everywhere +- Download, scan, denoise and activity progress now use one bar that fills the width it is given, with its text on + it, and a gentle sweep while the amount of work isn't known yet. Waits that only said "Selecting…", "Waiting to + start…" or "Installing…" now show a small turning arc beside the text, and a thumbnail still being made shows a + quiet placeholder that fits the theme in the grid, the filmstrip and Compare. Screen readers hear a progress + indicator with its percentage. +### Menu names that say what they are +- Switches in the menus are named for what they show, with the check mark saying whether it is on: Filmstrip ✓, + Focus Point ✓, Clipping ✓, Photo Counts ✓, Mask Overlay ✓ instead of Show Focus Point ✓. No two commands share a + name any more: View ▸ Detail is now Detail View (the Edit section Detail keeps its name), Window ▸ Edit is Edit + Panel, and Compare Before and After is Before/After Side by Side, next to Split and Top/Bottom. +- The preview cache command has one name, Discard Preview Cache, in File ▸ Previews and in the toast that confirms + it. Keyboard shortcuts are unchanged. +### Dropping files shows what will happen +- While files are dragged over the window, it dims and a card says what dropping will do: review the photos and + folders for import, import the presets, or open the face model. If a dialog is open, the card asks you to close it + first, and a drop is refused with a message instead of replacing the dialog. The same goes while an import is + running, and in the web version. +- The import review of a finished scan (a drop, or File ▸ Import) no longer replaces a dialog you opened in the + meantime; it opens as soon as that dialog is closed. +- Photos and folders dropped on the window now open the same import review as File ▸ Import, so you pick add, copy or + move, the destination and a preset before anything is imported. Settings ▸ Import ▸ "Import dropped files without + review" brings back the old behaviour of importing at once. +- In the web version, a dropped file the browser can't read, or that can't be imported, now shows a message instead of + failing silently. +- For scripts: `ui.dropFiles` hovers files over the window or drops them, as the operating system does. +### Counting many smart albums is faster +- Counting every smart album at once (the numbers in the Albums list, and the `albums.list` command) now works out an + album that other smart albums test (“album is …”) once for the whole library, instead of again for every album + that tests it. On a synthetic library of 312,000 photos with 77 smart albums, 51 of them testing one shared album, + the full count took about 14 s instead of about 23 s on a heavily loaded machine; the rest is the text rules of + the albums themselves. The counts are the same as before, including for albums that loop through each other. + ### Sharper resampling in HDR and panorama merges - Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a photo by half a pixel used to lose a third of its finest detail; now it keeps most of it, without halos around @@ -85,6 +194,20 @@ - `photo.setReference` refuses an `id` that is not a photo id (a string, a negative or fractional number) instead of pinning the active photo; only an absent or null `id` means the active photo. +### Edit Capture Time leaves a capture time it can't read as it was +- A capture time that is cut short, has seconds that aren't digits or has text after it (a Lightroom catalog's capture + time is copied as it is) is no longer shifted into a clean-looking wrong time: Edit Capture Time leaves the photo as + it was and says so, and its preview says the photo's time can't be read instead of previewing a wrong one. + `photo.setCaptureTime` counts those photos as `unreadable`. Set date & time still repairs such a photo selected on + its own; with other photos selected it says why it can't move them by the same amount. When the dialog can't make a + change (a time that isn't a date, a shift past year 9999), it now says why and stays open, instead of closing. + +### Month names in folder and file name templates +- Date templates take the month by name: `%B` gives January, `%b` gives Jan. Import ▸ Folders ▸ Custom template… + `{date:%Y}/{date:%B}` files photos into 2026/January, 2026/February…, the layout many existing libraries already + use. The names are English in every interface language, so folders don't change when the language does. Rename + Photos and export file names take them too. + ## 2026-10-20 ### Smart albums check File Path rules faster @@ -93,7 +216,6 @@ or "doesn't contain" rule took about 17 ms per pass instead of about 62 ms, around 3.5 times faster, with the same photos matched; about 6 ms of that is only reading the paths. Paths and rule text with letters beyond A–Z are compared as before. - ## 2026-10-19 ### AI Denoise on DirectX 12 with NVIDIA GTX 10-series cards @@ -116,6 +238,13 @@ - With several locations removed from Local, "Show N hidden locations" now lists them, so each comes back on its own (Restore All is there too). The row used to bring every hidden location back at once (issue #2114). +### Rating in a filtered view keeps your place +- When a filter shows only one-star photos and you take the star off a photo, it leaves the view and the photo after + it is now selected, so you carry on from where you were (issue #2312). The same goes for flags and colour labels, + in Picks and in smart albums. Before, the photo stayed selected although the grid no longer showed it, and → or + Auto Advance jumped back to the first photo of the view. With Auto Advance on, or with Shift+0–5, you land on the + photo after it: no photo is skipped. + ## 2026-10-18 ### Thumbnails of Lightroom HDR and panorama merges in a second @@ -187,7 +316,6 @@ one (issue #1507). LightCraft used to open the library without it, showing an incomplete catalog that the next save would write over the stored copy. It now leaves the library unopened, saves nothing and says which file couldn't be read; reload the page to try again. - ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve @@ -433,6 +561,15 @@ screen and nearly as tall counts as zoomed. - For scripts: a size set with `ui.resize` is remembered like any other, so a script that needs a size sets it. +### Keyword List: a keyword shows that one inside it is applied +- In the Keyword List (right panel ▸ Keywords), a keyword whose box is empty now carries a dot in it when the + selected photos have a keyword inside it, at any depth: with `Places › Italy › Rome` on a photo, `Places` and + `Italy` show the dot and `Rome` the tick. A folded keyword no longer hides that something in it is applied. + Resting on the box names what is inside. +- The tick and the dash are as they were, and are about the keyword itself: all of the selected photos have it, or + some do. A keyword the photos have themselves shows that, not the dot. A click on the box still gives the + keyword itself to the selected photos. + ### HDR merges keep the lens correction - An HDR made with Photo ▸ Photo Merge ▸ HDR now keeps the lens correction the photos carry (Sony's, Panasonic's and Leica's, and the one some DNGs carry, e.g. from drones): Optics ▸ Enable Lens Corrections straightens it as it diff --git a/flake.lock b/flake.lock index a9953fd12..29faf1a7c 100644 --- a/flake.lock +++ b/flake.lock @@ -3,17 +3,17 @@ "craft-fonts": { "flake": false, "locked": { - "lastModified": 1791722036, - "narHash": "sha256-mNnDzBcOp1X10dzmiW0iBAkb74QjuzMO4slWudxXCTE=", + "lastModified": 1792808533, + "narHash": "sha256-jZ3VBAJDofB6RvsS7GGV5lLT4xiShIjpz5XHBCD2oUc=", "owner": "storytold", "repo": "craft-fonts", - "rev": "3d9ab3a132ed0eff457e424226a435a901da30d3", + "rev": "b076417b5880bd4154bd22f00695b1d28c6bea83", "type": "github" }, "original": { "owner": "storytold", "repo": "craft-fonts", - "rev": "3d9ab3a132ed0eff457e424226a435a901da30d3", + "rev": "b076417b5880bd4154bd22f00695b1d28c6bea83", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 51dd933f9..faa7e1d2e 100644 --- a/flake.nix +++ b/flake.nix @@ -2,17 +2,21 @@ description = "LightCraft — photo library and non-destructive raw developer, in pure Rust"; inputs = { - # LightCraft needs edition 2024 and rustc ≥ 1.90, so the flake tracks unstable. Consumers on a + # LightCraft needs edition 2024 and rustc ≥ 1.95, so the flake tracks unstable. Consumers on a # release channel can retarget it: `inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";` nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - # Optional build input of the repository: the CJK fonts (BIZ UDPGothic, BIZ UDMincho, Noto Sans CJK SC, - # Shippori Mincho) that `crates/engine/build.rs` embeds when `CRAFT_FONTS_DIR` is set. Official - # releases always build with them; without them everything works but Japanese text has no - # glyphs. Pinned to the revision the release workflow uses — bump deliberately - # (`nix flake update craft-fonts`); craftrules standards/fonts.md. + # Optional build input of the repository: the default fonts (BIZ UDPGothic Regular/Bold, BIZ UDMincho, + # Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian, + # NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2, Noto Sans Hebrew and Noto Sans Devanagari) + # listed in craft-fonts' `fonts/manifest.txt`, which `crates/engine/build.rs` embeds when + # `CRAFT_FONTS_DIR` is set. The opt-in LXGW WenKai Lite face in `fonts/simplified-chinese-manifest.txt` + # is not part of that default set and is not embedded. Official releases always build with the + # default fonts; without them everything works but non-Latin text lacks glyphs. Pinned to the same + # revision CI, the release workflow and the standalone packaging helpers use — bump all of them + # together (`nix flake update craft-fonts`); craftrules standards/fonts.md. craft-fonts = { - url = "github:storytold/craft-fonts/3d9ab3a132ed0eff457e424226a435a901da30d3"; + url = "github:storytold/craft-fonts/b076417b5880bd4154bd22f00695b1d28c6bea83"; flake = false; }; }; diff --git a/packaging/linux/flatpak/ai.storyteller.lightcraft.yml b/packaging/linux/flatpak/ai.storyteller.lightcraft.yml index e9dbd244b..c983daae8 100644 --- a/packaging/linux/flatpak/ai.storyteller.lightcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.lightcraft.yml @@ -52,6 +52,8 @@ modules: - install -Dm644 packaging/linux/ai.storyteller.lightcraft.desktop /app/share/applications/ai.storyteller.lightcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.lightcraft.mime.xml /app/share/mime/packages/ai.storyteller.lightcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ + - install -Dm644 NOTICE /app/share/doc/lightcraft/NOTICE + - install -Dm644 assets/ATTRIBUTION.md /app/share/doc/lightcraft/ATTRIBUTION.md - >- version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml); install -d /app/share/metainfo; diff --git a/packaging/windows/lightcraft.wxs b/packaging/windows/lightcraft.wxs index 533c24d20..9e23e5430 100644 --- a/packaging/windows/lightcraft.wxs +++ b/packaging/windows/lightcraft.wxs @@ -206,6 +206,9 @@ <Component Id="LightcraftCli"> <File Id="LightcraftCliExe" Source="$(var.BinDir)\lightcraft-cli.exe" KeyPath="yes" /> </Component> + <Component Id="LightcraftNotice"> + <File Id="LightcraftNoticeFile" Source="$(var.BinDir)\NOTICE" KeyPath="yes" /> + </Component> <Component Id="LightcraftFontLicences"> <File Id="LightcraftFontLicencesFile" Source="$(var.BinDir)\FONT-LICENCES.txt" KeyPath="yes" /> </Component> diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 50aa77ea8..110d8b60c 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -116,6 +116,8 @@ $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'lightcraft.exe'), (Join-Path $Bin 'lightcraft-cli.exe') $Stage +# The bundled egui font MIT notice is required even without optional craft-fonts. +Copy-Item -LiteralPath (Join-Path $Root 'NOTICE') -Destination $Stage # Include the embedded fonts' unmodified OFL texts in both installer and portable releases. . (Join-Path $PSScriptRoot 'font-licences.ps1') Write-FontLicences (Join-Path $Stage 'FONT-LICENCES.txt') (Join-Path $Root 'assets\fonts\OFL-Inter.txt') $env:CRAFT_FONTS_DIR diff --git a/tools/install-sam3.sh b/tools/install-sam3.sh index 9787d4a49..625c2dab0 100755 --- a/tools/install-sam3.sh +++ b/tools/install-sam3.sh @@ -92,19 +92,20 @@ fi # resume the server refuses (416) starts over instead of counting as done. fetch() { file=$1 dest=$2 resume=${3:-} + # POSIX sh has no arrays: the auth option and its value travel as two plain words. if [ -n "$TOKEN" ]; then - CURL_AUTH=(--config "$AUTH_FILE") + auth_opt=--config auth_val=$AUTH_FILE else # not secret (no token is sent); kept in argv only to mirror the authenticated call shape - CURL_AUTH=(-H "X-No-Auth: 1") + auth_opt=-H auth_val="X-No-Auth: 1" fi - code=$(curl --proto '=https' -fsSL ${resume:+-C -} "${CURL_AUTH[@]}" -w '%{http_code}' -o "$dest" "$BASE/$file") || true + code=$(curl --proto '=https' -fsSL ${resume:+-C -} "$auth_opt" "$auth_val" -w '%{http_code}' -o "$dest" "$BASE/$file") || true case "$code" in 200|206) return 0 ;; 416) echo "the partial download of $file doesn't match the server's; starting over" >&2 rm -f "$dest" - code=$(curl --proto '=https' -fsSL "${CURL_AUTH[@]}" -w '%{http_code}' -o "$dest" "$BASE/$file") || true + code=$(curl --proto '=https' -fsSL "$auth_opt" "$auth_val" -w '%{http_code}' -o "$dest" "$BASE/$file") || true [ "$code" = 200 ] && return 0 echo "download of $REPO/$file failed ($code)" >&2; return 1 ;; 401|403) echo "access denied to $REPO/$file (has your request for facebook/sam3 been approved?)" >&2; return 1 ;; diff --git a/xtask/src/ci_lock.rs b/xtask/src/ci_lock.rs new file mode 100644 index 000000000..9504beb20 --- /dev/null +++ b/xtask/src/ci_lock.rs @@ -0,0 +1,152 @@ +//! One `cargo xtask ci` at a time per machine. +//! +//! Every CI run builds the workspace several times with one job per CPU. Several worktrees running +//! it at once (parallel agents) slow each other down far more than waiting in turn would, and make +//! the machine unusable meanwhile. So `ci` first takes one of `LIGHTCRAFT_CI_SLOTS` (default 1) +//! lock files in a per-user cache directory, shared by every checkout and worktree, and holds it +//! until it exits. The OS drops the lock when the process ends, crashed or not. +//! +//! - `LIGHTCRAFT_CI_SLOTS=N`: N runs at once (1–64). +//! - `LIGHTCRAFT_CI_LOCK=off`: no lock (e.g. a CI runner that only ever runs one job). +//! - `LIGHTCRAFT_CI_LOCK=<dir>`: keep the lock files there instead. + +use std::fs::{File, OpenOptions, TryLockError}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +const POLL: Duration = Duration::from_secs(2); + +/// A held CI slot; dropping it releases the slot. +pub struct Held { + _file: File, + owner: PathBuf, +} + +impl Drop for Held { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.owner); + } +} + +/// Where the lock files live, or `None` when the lock is switched off. +fn lock_dir() -> Option<PathBuf> { + match std::env::var_os("LIGHTCRAFT_CI_LOCK") { + Some(v) if matches!(v.to_str(), Some("off" | "0" | "false" | "no")) => return None, + Some(v) if !v.is_empty() => return Some(PathBuf::from(v)), + _ => {} + } + let cache = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("LOCALAPPDATA").map(PathBuf::from)) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .unwrap_or_else(std::env::temp_dir); + Some(cache.join("lightcraft")) +} + +fn slots() -> usize { + std::env::var("LIGHTCRAFT_CI_SLOTS").ok().and_then(|s| s.trim().parse().ok()).unwrap_or(1).clamp(1, 64) +} + +fn slot_path(dir: &Path, slot: usize) -> PathBuf { + if slot == 0 { dir.join("ci.lock") } else { dir.join(format!("ci-{}.lock", slot + 1)) } +} + +fn owner_path(lock: &Path) -> PathBuf { + lock.with_extension("lock.owner") +} + +/// What to do when the lock itself can't be set up (read-only home, sandbox, network mount). +const LOCK_HINT: &str = "set LIGHTCRAFT_CI_LOCK=off to run without the lock, or LIGHTCRAFT_CI_LOCK=<dir> to keep it elsewhere"; + +/// Take the first free slot of `slots` in `dir`, or `None` when all are held. +pub fn try_acquire(dir: &Path, slots: usize, owner: &str) -> Result<Option<Held>, String> { + std::fs::create_dir_all(dir).map_err(|e| format!("ci lock: {}: {e} ({LOCK_HINT})", dir.display()))?; + for slot in 0..slots.max(1) { + let path = slot_path(dir, slot); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path) + .map_err(|e| format!("ci lock: {}: {e} ({LOCK_HINT})", path.display()))?; + match file.try_lock() { + Ok(()) => { + // a sidecar file, not the lock file: Windows locks are mandatory, so others couldn't read it + let owner_file = owner_path(&path); + let _ = std::fs::File::create(&owner_file).and_then(|mut f| f.write_all(owner.as_bytes())); + return Ok(Some(Held { _file: file, owner: owner_file })); + } + Err(TryLockError::WouldBlock) => continue, + Err(TryLockError::Error(e)) => return Err(format!("ci lock: {}: {e} ({LOCK_HINT})", path.display())), + } + } + Ok(None) +} + +/// Who holds each slot, as they wrote it. +fn holders(dir: &Path, slots: usize) -> Vec<String> { + (0..slots.max(1)) + .filter_map(|slot| std::fs::read_to_string(owner_path(&slot_path(dir, slot))).ok()) + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Wait for a CI slot (see the module docs). `None` when the lock is off. +pub fn acquire(root: &Path, command: &str) -> Result<Option<Held>, String> { + let Some(dir) = lock_dir() else { + return Ok(None); + }; + let slots = slots(); + let owner = format!("pid {} in {}: {command}", std::process::id(), root.display()); + if let Some(held) = try_acquire(&dir, slots, &owner)? { + return Ok(Some(held)); + } + let waiting = Instant::now(); + eprintln!("=== ci: waiting for another CI run to finish ({slots} at a time; LIGHTCRAFT_CI_SLOTS changes it) ==="); + for h in holders(&dir, slots) { + eprintln!(" running: {h}"); + } + loop { + std::thread::sleep(POLL); + if let Some(held) = try_acquire(&dir, slots, &owner)? { + eprintln!("=== ci: waited {:.0} s for a CI slot ===", waiting.elapsed().as_secs_f64()); + return Ok(Some(held)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("xtask-ci-lock-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn one_slot_is_exclusive_until_dropped() { + let dir = scratch("one"); + let first = try_acquire(&dir, 1, "first").unwrap().expect("free slot"); + assert!(try_acquire(&dir, 1, "second").unwrap().is_none()); + assert_eq!(holders(&dir, 1), ["first"]); + drop(first); + assert!(holders(&dir, 1).is_empty()); + assert!(try_acquire(&dir, 1, "third").unwrap().is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn more_slots_admit_more_runs() { + let dir = scratch("two"); + let a = try_acquire(&dir, 2, "a").unwrap(); + let b = try_acquire(&dir, 2, "b").unwrap(); + assert!(a.is_some() && b.is_some()); + assert!(try_acquire(&dir, 2, "c").unwrap().is_none()); + assert_eq!(holders(&dir, 2), ["a", "b"]); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/xtask/src/ci_quick.rs b/xtask/src/ci_quick.rs new file mode 100644 index 000000000..3242c95af --- /dev/null +++ b/xtask/src/ci_quick.rs @@ -0,0 +1,349 @@ +//! `cargo xtask ci --quick`: the checks a branch's own changes can break, for a pre-push check. +//! +//! The full `ci` builds the workspace four ways (clippy, heif, tests, wasm) plus the CLI for the +//! parity check, ~23 minutes cold. `--quick` looks at what changed since the merge base with +//! `origin/main` (or `--base REF`), committed or not, and runs: +//! +//! - fmt, layers, assets: always (seconds); +//! - clippy on the whole workspace (check builds only; catches callers broken by an API change); +//! - the tests of the changed packages only (not their dependents); +//! - heif when `lightcraft-codecs` / `lightcraft-heif` changed; +//! - parity when the tracker docs, a command/control crate, or a file's path changed (renames and +//! deletions: the tracker cites paths); +//! - the wasm check of the changed packages that must build for wasm32. +//! +//! A file outside any package that a crate compiles in (`include_str!` of `docs/whats-new.md`, +//! fonts and camera profiles under `assets/`, ...) counts as a change to that crate (`EMBEDDED`). +//! A change to a workspace-wide file (root `Cargo.toml`, `Cargo.lock`, lint or toolchain config) +//! tests everything. The full `ci` (GitHub's CI on every PR) stays the merge gate. + +use std::collections::BTreeSet; +use std::path::Path; +use std::process::Command; + +/// Root-level files whose change can affect every package. +const WORKSPACE_WIDE: &[&str] = + &["Cargo.toml", "Cargo.lock", "clippy.toml", "rustfmt.toml", ".rustfmt.toml", "rust-toolchain", "rust-toolchain.toml"]; +const WORKSPACE_WIDE_DIRS: &[&str] = &[".cargo/"]; +/// Packages whose ids or paths the parity tracker cites (commands, develop controls, UI menus). +const PARITY_PACKAGES: &[&str] = &["lightcraft-engine", "lightcraft-develop", "lightcraft-ui-egui", "lightcraft-cli"]; +const PARITY_DOCS: &[&str] = &["docs/parity-checklist.md", "docs/gaps.md"]; +const HEIF_PACKAGES: &[&str] = &["lightcraft-codecs", "lightcraft-heif"]; +/// Files outside a package's directory that it compiles in (`include_str!` / `include_bytes!`, +/// a build script's `rerun-if-changed`): a change to one counts as a change to those packages. +/// An entry ending in `/` covers the directory. `embedded_files_are_mapped` keeps this complete. +const EMBEDDED: &[(&str, &[&str])] = &[ + ("assets/app-icon/", &["lightcraft"]), + ("assets/camera-profiles/", &["lightcraft-engine"]), + ("assets/camera-tone/", &["lightcraft-engine"]), + ("assets/fonts/", &["lightcraft-engine", "lightcraft-ui-egui"]), + ("contributors/", &["lightcraft-ui-egui"]), + ("docs/whats-new.md", &["lightcraft-ui-egui"]), + ("docs/raw/coverage-sweep.tsv", &["lightcraft-raw"]), + ("docs/raf-corpus.sha256", &["lightcraft-raw"]), + ("xtask/src/main.rs", &["lightcraft-raw"]), + ("crates/ui-egui/src/menus.rs", &["xtask"]), +]; + +/// The packages that compile in `path` from outside their own directory. +fn embedders(path: &str) -> impl Iterator<Item = &'static str> + '_ { + EMBEDDED.iter().filter(move |(p, _)| if p.ends_with('/') { path.starts_with(p) } else { path == *p }).flat_map(|(_, pkgs)| pkgs.iter().copied()) +} + +/// A changed path, relative to the workspace root; `gone` when deleted or renamed away. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Changed { + pub path: String, + pub gone: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Plan { + /// Workspace packages with a changed file. + pub packages: BTreeSet<String>, + /// A workspace-wide file changed: test everything. + pub workspace_wide: bool, + pub heif: bool, + pub parity: bool, + /// What the changes were measured against, for the log and the report. + pub base: String, + pub files: usize, +} + +impl Plan { + /// Whether anything compiled changed (otherwise clippy and the tests are skipped). + pub fn rust(&self) -> bool { + self.workspace_wide || !self.packages.is_empty() + } +} + +/// Decide what to run. `packages` are (name, directory relative to the root, `/`-separated). +pub fn plan(changed: &[Changed], packages: &[(String, String)], base: String) -> Plan { + let mut p = Plan { base, files: changed.len(), ..Plan::default() }; + for c in changed { + let path = c.path.as_str(); + if WORKSPACE_WIDE.contains(&path) || WORKSPACE_WIDE_DIRS.iter().any(|d| path.starts_with(d)) { + p.workspace_wide = true; + } + if PARITY_DOCS.contains(&path) || c.gone { + p.parity = true; + } + // the innermost package directory containing the file + let owner = packages + .iter() + .filter(|(_, dir)| !dir.is_empty() && path.strip_prefix(dir.as_str()).is_some_and(|rest| rest.starts_with('/'))) + .max_by_key(|(_, dir)| dir.len()); + if let Some((name, _)) = owner { + p.packages.insert(name.clone()); + } + for name in embedders(path) { + if packages.iter().any(|(n, _)| n == name) { + p.packages.insert(name.to_owned()); + } + } + } + p.heif = p.workspace_wide || HEIF_PACKAGES.iter().any(|h| p.packages.contains(*h)); + p.parity |= p.workspace_wide || PARITY_PACKAGES.iter().any(|h| p.packages.contains(*h)); + p +} + +fn git(root: &Path, args: &[&str]) -> Result<String, String> { + let out = Command::new("git").args(args).current_dir(root).output().map_err(|e| format!("git {}: {e}", args.join(" ")))?; + if !out.status.success() { + return Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim())); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// Parse `git diff --name-status -z` output: NUL-separated fields, a status then its path, or +/// two paths (old, new) for a rename or copy. `-z` leaves non-ASCII paths unquoted. +fn parse_name_status(out: &str) -> Vec<Changed> { + let mut v = Vec::new(); + let mut fields = out.split('\0'); + while let Some(status) = fields.next() { + let Some(kind) = status.trim().chars().next() else { continue }; + let Some(first) = fields.next() else { break }; + match kind { + // renamed / copied: old path, new path + 'R' | 'C' => { + v.push(Changed { path: first.to_owned(), gone: kind == 'R' }); + if let Some(second) = fields.next() { + v.push(Changed { path: second.to_owned(), gone: false }); + } + } + 'D' => v.push(Changed { path: first.to_owned(), gone: true }), + _ => v.push(Changed { path: first.to_owned(), gone: false }), + } + } + v +} + +/// Parse NUL-separated `git ls-files -z` output. +fn parse_paths_z(out: &str) -> impl Iterator<Item = Changed> + '_ { + out.split('\0').filter(|p| !p.is_empty()).map(|p| Changed { path: p.to_owned(), gone: false }) +} + +/// The files changed since the merge base with `base` (default `origin/main`, else `main`): +/// committed, staged, unstaged and untracked. Returns the base description and the files. +pub fn changed_files(root: &Path, base: Option<&str>) -> Result<(String, Vec<Changed>), String> { + let base = match base { + Some(b) => b.to_owned(), + None => ["origin/main", "main"] + .into_iter() + .find(|b| git(root, &["rev-parse", "--verify", "--quiet", &format!("{b}^{{commit}}")]).is_ok()) + .ok_or("no origin/main or main to compare with; pass --base REF")? + .to_owned(), + }; + let merge_base = git(root, &["merge-base", "HEAD", &base])?.trim().to_owned(); + let mut files = parse_name_status(&git(root, &["diff", "--name-status", "-z", "-M", &merge_base])?); + files.extend(parse_paths_z(&git(root, &["ls-files", "-z", "--others", "--exclude-standard"])?)); + let short: String = merge_base.chars().take(10).collect(); + Ok((format!("{base} (merge base {short})"), files)) +} + +/// (name, root-relative directory) of each workspace package, from `cargo metadata`. +pub fn packages(root: &Path, metadata: &serde_json::Value) -> Vec<(String, String)> { + let roots: Vec<std::path::PathBuf> = [Some(root.to_path_buf()), root.canonicalize().ok()].into_iter().flatten().collect(); + metadata["packages"] + .as_array() + .into_iter() + .flatten() + .filter_map(|p| { + let name = p["name"].as_str()?; + let dir = Path::new(p["manifest_path"].as_str()?).parent()?; + let rel = roots.iter().find_map(|r| dir.strip_prefix(r).ok())?; + let rel = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"); + Some((name.to_owned(), rel)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ch(path: &str) -> Changed { + Changed { path: path.into(), gone: false } + } + + fn pkgs() -> Vec<(String, String)> { + [ + ("lightcraft-raw", "crates/raw"), + ("lightcraft-codecs", "crates/codecs"), + ("lightcraft-engine", "crates/engine"), + ("lightcraft-cli", "apps/lightcraft-cli"), + ("xtask", "xtask"), + ("lightcraft-raw-nested", "crates/raw/nested"), + ] + .into_iter() + .map(|(a, b)| (a.to_owned(), b.to_owned())) + .collect() + } + + #[test] + fn changed_files_pick_their_innermost_package() { + let p = plan(&[ch("crates/raw/src/lib.rs"), ch("crates/raw/nested/src/x.rs"), ch("crates/rawx/y.rs")], &pkgs(), "b".into()); + assert_eq!(p.packages.iter().map(String::as_str).collect::<Vec<_>>(), ["lightcraft-raw", "lightcraft-raw-nested"]); + assert!(p.rust() && !p.workspace_wide && !p.heif && !p.parity); + } + + #[test] + fn docs_only_skip_the_builds_but_tracker_docs_run_parity() { + let p = plan(&[ch("README.md"), ch("docs/raw-parity.md")], &pkgs(), "b".into()); + assert!(!p.rust() && !p.parity); + let p = plan(&[ch("docs/gaps.md")], &pkgs(), "b".into()); + assert!(!p.rust() && p.parity); + } + + #[test] + fn workspace_files_heif_and_parity_triggers() { + let p = plan(&[ch("Cargo.lock")], &pkgs(), "b".into()); + assert!(p.workspace_wide && p.rust() && p.heif && p.parity); + let p = plan(&[ch("crates/codecs/src/heif.rs")], &pkgs(), "b".into()); + assert!(p.heif && !p.parity); + let p = plan(&[ch("crates/engine/src/commands.rs")], &pkgs(), "b".into()); + assert!(p.parity && !p.heif); + let p = plan(&[Changed { path: "crates/raw/src/old.rs".into(), gone: true }], &pkgs(), "b".into()); + assert!(p.parity, "a removed path may be cited by the tracker"); + } + + #[test] + fn name_status_parses_renames_and_deletions() { + let v = parse_name_status("M\0a.rs\0R087\0old.rs\0new.rs\0D\0gone.rs\0C100\0src.rs\0copy.rs\0A\0added.rs\0"); + assert_eq!( + v, + [ + ch("a.rs"), + Changed { path: "old.rs".into(), gone: true }, + ch("new.rs"), + Changed { path: "gone.rs".into(), gone: true }, + ch("src.rs"), + ch("copy.rs"), + ch("added.rs"), + ] + ); + // truncated output: no panic, keeps what it could read + assert_eq!(parse_name_status("M\0a.rs\0R100\0x.rs"), [ch("a.rs"), Changed { path: "x.rs".into(), gone: true }]); + } + + #[test] + fn non_ascii_paths_still_map_to_their_package() { + // without -z, git prints these quoted and octal-escaped ("crates/raw/src/\303\251t\303\251.rs") + let v = parse_name_status("M\0crates/raw/src/été.rs\0R100\0crates/engine/a.rs\0crates/engine/日本.rs\0"); + assert_eq!(v[0], ch("crates/raw/src/été.rs")); + assert_eq!(v[2], ch("crates/engine/日本.rs")); + let untracked: Vec<_> = parse_paths_z("crates/codecs/ünï code.rs\0").collect(); + assert_eq!(untracked, [ch("crates/codecs/ünï code.rs")]); + let p = plan(&[v, untracked].concat(), &pkgs(), "b".into()); + assert_eq!(p.packages.iter().map(String::as_str).collect::<Vec<_>>(), ["lightcraft-codecs", "lightcraft-engine", "lightcraft-raw"]); + } + + #[test] + fn embedded_files_outside_a_crate_test_the_crates_that_embed_them() { + let p = plan(&[ch("docs/raw/coverage-sweep.tsv")], &pkgs(), "b".into()); + assert!(p.rust()); + assert_eq!(p.packages.iter().map(String::as_str).collect::<Vec<_>>(), ["lightcraft-raw"]); + let p = plan(&[ch("assets/fonts/Inter-Regular.ttf")], &pkgs(), "b".into()); + assert!(p.rust() && p.packages.contains("lightcraft-engine")); + // a consumer missing from the workspace's package list is not invented + assert!(!p.packages.contains("lightcraft-ui-egui")); + let p = plan(&[ch("assets/ATTRIBUTION.md"), ch("docs/raw-parity.md")], &pkgs(), "b".into()); + assert!(!p.rust(), "only files something compiles in"); + } + + /// Every `include_str!` / `include_bytes!` of a file outside its package, and every `"../…"` + /// path in a build script, is listed in `EMBEDDED` for that package. + #[test] + fn embedded_files_are_mapped() { + use std::path::{Component, PathBuf}; + fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() && p.file_name().is_some_and(|n| n != "target") { + rs_files(&p, out); + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } + } + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask is in the workspace").to_path_buf(); + // `a/b/../c` -> `a/c`, relative to the root; None outside it + let normalize = |p: &Path| -> Option<String> { + let mut parts: Vec<String> = Vec::new(); + for c in p.strip_prefix(&root).ok()?.components() { + match c { + Component::ParentDir => { + parts.pop()?; + } + Component::Normal(s) => parts.push(s.to_string_lossy().into_owned()), + _ => {} + } + } + Some(parts.join("/")) + }; + let mut files = Vec::new(); + for d in ["crates", "apps", "xtask"] { + rs_files(&root.join(d), &mut files); + } + let (mut checked, mut missing) = (0, Vec::new()); + for file in files { + let Ok(src) = std::fs::read_to_string(&file) else { continue }; + // the package: the nearest directory with a Cargo.toml + let Some(pkg_dir) = file.ancestors().skip(1).find(|d| d.join("Cargo.toml").is_file()) else { continue }; + let manifest = std::fs::read_to_string(pkg_dir.join("Cargo.toml")).unwrap_or_default(); + let Some(name) = manifest.lines().find_map(|l| l.strip_prefix("name = \"")?.strip_suffix('"')) else { continue }; + let Some(pkg_rel) = normalize(pkg_dir) else { continue }; + let mut targets = Vec::new(); + for mac in ["include_str!(", "include_bytes!("] { + for (i, _) in src.match_indices(mac) { + let rest = src[i + mac.len()..].trim_start(); + if let Some(lit) = rest.strip_prefix('"').and_then(|r| r.split('"').next()) { + targets.push(file.parent().expect("a file has a directory").join(lit)); + } + } + } + if file.file_name().is_some_and(|n| n == "build.rs") { + for (i, _) in src.match_indices("\"../") { + if let Some(lit) = src[i + 1..].split('"').next() { + targets.push(pkg_dir.join(lit)); + } + } + } + for t in targets { + // outside the workspace (`../craft-fonts`), not a real file, or inside the package + let Some(rel) = normalize(&t) else { continue }; + if !root.join(&rel).exists() || rel.starts_with(&format!("{pkg_rel}/")) { + continue; + } + checked += 1; + // a directory (`rerun-if-changed=../../contributors`) needs a `dir/` entry + let rel = if root.join(&rel).is_dir() { format!("{rel}/") } else { rel }; + if !embedders(&rel).any(|p| p == name) { + missing.push(format!("{rel} (compiled into {name} by {})", file.display())); + } + } + } + assert!(checked > 0, "found no embedded files: the scan is broken"); + assert!(missing.is_empty(), "add these to ci_quick::EMBEDDED:\n{}", missing.join("\n")); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e9a9716ce..088103f78 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,8 @@ mod assets; mod bench; +mod ci_lock; +mod ci_quick; mod ci_report; mod ico; mod layers; @@ -34,14 +36,18 @@ commands: web [--serve [port]] [--dev] build the browser app (apps/lightcraft-web) into <target>/web-site/; --serve serves it on http://127.0.0.1:<port> (default 8080) - ci [--hardware] [--summary-md] + ci [--hardware] [--summary-md] [--quick [--base REF]] fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm (stops at first failure). Tests run under cargo test (cargo nextest with LIGHTCRAFT_NEXTEST=1), without examples, then doctests with cargo test --doc; after them it prints the translation gaps report (never a failure). Writes target/ci/report.json (machine, GPU adapters, data, steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on - every backend with a hardware adapter; --summary-md prints the Markdown summary + every backend with a hardware adapter; --summary-md prints the Markdown summary. + --quick: only what the changes since the merge base with origin/main (or REF) can + break: clippy, the changed packages' tests, and heif / parity / wasm when they apply. + One ci runs at a time per machine; others wait (LIGHTCRAFT_CI_SLOTS=N for N at once, + LIGHTCRAFT_CI_LOCK=off to skip the lock) corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256 stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -239,6 +245,60 @@ fn doctests() -> (Command, String) { (c, "cargo test --workspace --doc".to_owned()) } +/// `ci --quick`'s tests: build every workspace test target, then run only the test binaries of +/// `packages`. Building with `-p` instead would resolve features per selection: different +/// artifacts from `ci`'s workspace build, so it would recompile ~130 crates. Doc tests are left to +/// the full `ci`. +fn test_selected(root: &Path, packages: &std::collections::BTreeSet<String>, skip_log: &Path) -> Result<(), String> { + let manifests: std::collections::BTreeMap<String, String> = metadata()?["packages"] + .as_array() + .into_iter() + .flatten() + .filter_map(|p| Some((p["manifest_path"].as_str()?.to_owned(), p["name"].as_str()?.to_owned()))) + .filter(|(_, name)| packages.contains(name)) + .collect(); + let what = "cargo test --workspace --no-run --lib --bins --tests"; + eprintln!("$ {what} (then the test binaries of {})", packages.iter().map(String::as_str).collect::<Vec<_>>().join(", ")); + let out = cargo() + .args(with_test_targets(&["test", "--workspace", "--no-run", "--message-format=json-render-diagnostics"])) + .stderr(std::process::Stdio::inherit()) + .output() + .map_err(|e| format!("{what}: failed to spawn: {e}"))?; + if !out.status.success() { + return Err(format!("{what}: exited with {}", out.status)); + } + let mut binaries = Vec::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) else { continue }; + if msg["reason"] != "compiler-artifact" || msg["profile"]["test"] != true { + continue; + } + let (Some(exe), Some(manifest)) = (msg["executable"].as_str(), msg["manifest_path"].as_str()) else { continue }; + if let Some(name) = manifests.get(manifest) { + binaries.push((name.clone(), manifest.to_owned(), exe.to_owned())); + } + } + if binaries.is_empty() { + println!("no test binaries in {}", packages.iter().map(String::as_str).collect::<Vec<_>>().join(", ")); + return Ok(()); + } + let mut failed = Vec::new(); + for (name, manifest, exe) in &binaries { + let dir = Path::new(manifest).parent().unwrap_or(root); + let mut c = Command::new(exe); + c.current_dir(dir).env("CARGO_MANIFEST_DIR", dir).env("CARGO_PKG_NAME", name).env(ci_report::SKIP_LOG_ENV, skip_log); + if std::env::var_os("RUST_TEST_THREADS").is_none() { + // as the cargo runs of ci (`cargo()`): more threads make the frame-budget tests flaky + c.env("RUST_TEST_THREADS", std::thread::available_parallelism().map_or(4, |n| n.get()).min(4).to_string()); + } + let file = Path::new(exe).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(); + if run(c, &format!("{name}: {file}")).is_err() { + failed.push(format!("{name} ({file})")); + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("tests failed in {}", failed.join(", "))) } +} + /// The test that prints the messages each language lacks (shown in English); it always passes. const TRANSLATION_GAPS_TEST: &str = "i18n::tests::translation_gaps_report"; @@ -338,7 +398,16 @@ fn wasm_set() -> Result<Vec<String>, String> { /// One `cargo check` over the whole set; `--keep-going` reports every crate that fails. fn cmd_wasm() -> Result<(), String> { - let set = wasm_set()?; + cmd_wasm_for(None) +} + +/// The wasm check of the wasm-safe crates, or only those of them in `only` (`ci --quick`). +fn cmd_wasm_for(only: Option<&std::collections::BTreeSet<String>>) -> Result<(), String> { + let set: Vec<String> = wasm_set()?.into_iter().filter(|p| only.is_none_or(|o| o.contains(p))).collect(); + if set.is_empty() { + println!("wasm32-unknown-unknown check: no changed crate builds for wasm32"); + return Ok(()); + } let mut c = cargo(); c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); for pkg in &set { @@ -350,10 +419,36 @@ fn cmd_wasm() -> Result<(), String> { fn cmd_ci(args: &[&str]) -> Result<(), String> { let hardware = args.contains(&"--hardware"); let print_md = args.contains(&"--summary-md"); - if let Some(other) = args.iter().find(|a| !matches!(**a, "--hardware" | "--summary-md")) { + let quick = args.contains(&"--quick"); + let base_at = args.iter().position(|a| *a == "--base"); + let base = base_at.map(|i| args.get(i + 1).copied().ok_or("ci: --base needs a ref")).transpose()?; + let unknown = args + .iter() + .enumerate() + .find(|(i, a)| !matches!(**a, "--hardware" | "--summary-md" | "--quick" | "--base") && base_at.is_none_or(|b| *i != b + 1)); + if let Some((_, other)) = unknown { return Err(format!("ci: unknown option `{other}`\n\n{USAGE}")); } + if base.is_some() && !quick { + return Err("ci: --base only applies to --quick".into()); + } let root = root(); + let command = std::iter::once("cargo xtask ci").chain(args.iter().copied()).collect::<Vec<_>>().join(" "); + // held until ci returns: one run at a time per machine (ci_lock.rs) + let _slot = ci_lock::acquire(&root, &command)?; + let plan = if quick { + let (base, files) = ci_quick::changed_files(&root, base)?; + let plan = ci_quick::plan(&files, &ci_quick::packages(&root, &metadata()?), base); + let packages: Vec<&str> = plan.packages.iter().map(String::as_str).collect(); + eprintln!("=== ci --quick: {} changed file(s) since {} ===", plan.files, plan.base); + eprintln!(" packages: {}", if packages.is_empty() { "none".to_owned() } else { packages.join(", ") }); + if plan.workspace_wide { + eprintln!(" a workspace-wide file changed: testing every package"); + } + Some(plan) + } else { + None + }; let report_dir = ci_report::prepare(&root)?; let started = std::time::Instant::now(); let started_unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); @@ -378,6 +473,16 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> { }; type Step<'a> = (&'static str, Box<dyn Fn() -> Result<(), String> + 'a>); + // `--quick`: which steps apply (all of them in a full run) and the packages to test + let wants = |step: &str| match &plan { + None => true, + Some(p) => match step { + "clippy" | "test" | "wasm" => p.rust(), + "heif" => p.heif, + "parity" => p.parity, + _ => true, + }, + }; let mut steps: Vec<Step> = vec![ ( "fmt", @@ -412,12 +517,13 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> { ), ( "test", - Box::new(|| { - test(tests(&with_test_targets(&["--workspace"])), "test")?; - test(doctests(), "test") + Box::new(|| match &plan { + Some(p) if !p.workspace_wide => test_selected(&root, &p.packages, &ci_report::skip_log(&root, "test")), + _ => test(tests(&with_test_targets(&["--workspace"])), "test").and_then(|()| test(doctests(), "test")), }), ), ]; + steps.retain(|(name, _)| wants(name)); if hardware { steps.push(( "gpu-backends", @@ -449,10 +555,17 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> { }), )); } - steps.push(("parity", Box::new(|| parity::run(&root, false)))); + if wants("parity") { + steps.push(("parity", Box::new(|| parity::run(&root, false)))); + } steps.push(("layers", Box::new(cmd_layers))); steps.push(("assets", Box::new(|| assets::run(&root)))); - steps.push(("wasm", Box::new(cmd_wasm))); + if wants("wasm") { + match plan.as_ref().filter(|p| !p.workspace_wide) { + Some(p) => steps.push(("wasm", Box::new(|| cmd_wasm_for(Some(&p.packages))))), + None => steps.push(("wasm", Box::new(cmd_wasm))), + } + } let mut results = Vec::new(); let mut failure = None; @@ -462,7 +575,7 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> { let r = f(); let status = if r.is_ok() { "ok" } else { "fail" }; results.push(serde_json::json!({"name": name, "status": status, "seconds": t.elapsed().as_secs_f64()})); - if *name == "test" { + if *name == "test" && plan.is_none() { // right after the workspace tests, before parity and wasm build other feature sets probe(); translation_gaps(); @@ -473,7 +586,8 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> { } } if gpu.borrow().is_null() { - *gpu.borrow_mut() = serde_json::json!({"error": "not probed: the test step did not run"}); + let why = if plan.is_some() { "not probed: --quick" } else { "not probed: the test step did not run" }; + *gpu.borrow_mut() = serde_json::json!({"error": why}); } let done: Vec<&str> = steps.iter().take(results.len()).map(|(n, _)| *n).collect(); match &failure { @@ -497,7 +611,8 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> { let ram_mb = available_ram_mb(); let report = serde_json::json!({ "schema": ci_report::SCHEMA, - "command": std::iter::once("cargo xtask ci").chain(args.iter().copied()).collect::<Vec<_>>().join(" "), + "command": command, + "quick": plan.as_ref().map(|p| serde_json::json!({"base": p.base, "files": p.files, "packages": p.packages, "workspace_wide": p.workspace_wide})), "result": if failure.is_some() { "fail" } else { "pass" }, "failed_step": failure.as_ref().map(|(n, _)| *n), "started_unix": started_unix,