diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 71d3b3063..dff954e93 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -37,3 +37,27 @@ jobs: run: | ./packaging/windows/test-install-folder.ps1 -Arch x64 ./packaging/windows/test-install-folder.ps1 -Arch x86 + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 332193998..03f57e508 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -32,3 +32,5 @@ jobs: with: app-name: lightcraft app-dir: LightCraft + install-relative-path: Programs/ArtCraft/LightCraft + install-scope: perUser diff --git a/AGENTS.md b/AGENTS.md index 959d2808f..4044a6563 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate +- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic in tests only; indexing is not denied. Every production crate root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit integration tests, examples and benches. New crates start with the attribute. @@ -156,3 +156,8 @@ evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the work GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 416ffc0a8..9b384fc6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,10 +52,13 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Last-resort guard:** a panic hook plus `catch_unwind` around command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keep `panic = "unwind"` on native. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Enforced by clippy: root `clippy.toml` allows unwrap/expect/panic/indexing in tests only, and every production crate - root (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, - clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Not `[workspace.lints]`: those would also hit - integration tests, examples and benches. New crates start with the attribute. +- Enforced by clippy: root `clippy.toml` keeps unwrap/expect/panic allowances scoped to tests; indexing is not denied. The workspace + denies `cognitive_complexity`, `too_many_arguments` and `wildcard_imports`; members inherit them with + `[lints] workspace = true`, and `sysmem` repeats those denies in its local lint table. Every production crate root + (`lib.rs`, each binary's `main.rs`) carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, + clippy::unimplemented, clippy::todo, clippy::unreachable)]`. Every new workspace crate must include + `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`, and new production crate roots include + the attribute before modules or other crate items; crate-level `//!` documentation may precede it. ## Non-negotiables - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…). @@ -149,3 +152,8 @@ photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 4b52046c4..17f738ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -888,7 +888,7 @@ dependencies = [ "safetensors 0.7.0", "thiserror 2.0.21", "yoke 0.8.3", - "zip 7.2.0", + "zip 7.4.0", ] [[package]] @@ -1238,13 +1238,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -1257,7 +1258,7 @@ dependencies = [ "verifysign", "winreg", "winsafe", - "zip 9.1.0", + "zip 9.1.3", ] [[package]] @@ -1870,7 +1871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3535,7 +3536,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "craft-update", @@ -3562,7 +3563,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "crc32fast", @@ -3570,17 +3571,19 @@ dependencies = [ "lightcraft-geom", "lightcraft-meta", "lightcraft-raw", + "lightcraft-testkit", "log", "proptest", "serde", "serde_json", "thiserror 2.0.21", "web-time", + "winapi-util", ] [[package]] name = "lightcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3596,7 +3599,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.14.0" +version = "0.15.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3627,7 +3630,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3636,7 +3639,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.14.0" +version = "0.15.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3653,7 +3656,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "lightcraft-raster", @@ -3669,7 +3672,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3681,7 +3684,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "ab_glyph", "chrono", @@ -3725,7 +3728,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3740,7 +3743,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "rustls", @@ -3751,7 +3754,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.14.0" +version = "0.15.0" dependencies = [ "miniz_oxide 0.9.1", "serde", @@ -3759,7 +3762,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -3767,7 +3770,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.14.0" +version = "0.15.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3787,7 +3790,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.14.0" +version = "0.15.0" dependencies = [ "heic-rs", "rayon", @@ -3795,14 +3798,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.14.0" +version = "0.15.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3811,7 +3814,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3822,7 +3825,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3838,7 +3841,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "lightcraft-geom", @@ -3851,7 +3854,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "image", @@ -3870,7 +3873,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.14.0" +version = "0.15.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3882,7 +3885,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.14.0" +version = "0.15.0" dependencies = [ "libc", "lightcraft-color", @@ -3894,7 +3897,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3918,7 +3921,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.14.0" +version = "0.15.0" dependencies = [ "image", "lightcraft-color", @@ -3929,7 +3932,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.14.0" +version = "0.15.0" dependencies = [ "candle-core", "candle-nn", @@ -3943,7 +3946,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-raster", "serde", @@ -3952,15 +3955,15 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.14.0" +version = "0.15.0" [[package]] name = "lightcraft-testkit" -version = "0.14.0" +version = "0.15.0" [[package]] name = "lightcraft-tiff" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -3969,7 +3972,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-ui", "craft-update", @@ -4000,7 +4003,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "crc32fast", "eframe", @@ -4881,7 +4884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5840,7 +5843,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5853,7 +5856,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "subtle", "zeroize", ] @@ -5893,11 +5896,11 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5949,9 +5952,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -6476,10 +6479,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7402,7 +7405,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7817,7 +7820,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "brotli", "flate2", @@ -8084,9 +8087,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.2.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "crc32fast", "indexmap", @@ -8096,9 +8099,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.0" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 0686b502d..a59de44ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -12,7 +12,8 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } @@ -54,6 +55,7 @@ log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" +winapi-util = "0.1" # lightcraft-catalog reads a file's volume serial number and file index with it on Windows proptest = "1" bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" @@ -121,9 +123,11 @@ web-sys = "0.3" unsafe_code = "deny" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" needless_range_loop = "allow" type_complexity = "allow" excessive_precision = "allow" diff --git a/README.md b/README.md index 71f30d4c2..ba094749c 100644 --- a/README.md +++ b/README.md @@ -563,9 +563,20 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco | arm64 (Snapdragon and other ARM PCs) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by -default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an +Installers and executables are code-signed. The installer asks who to install for and where (for your account only, in +`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`, by default; "Everyone on this computer" uses +`C:\Program Files\ArtCraft\LightCraft` (`Program Files (x86)` for the 32-bit build); upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an option to start it. For unattended installs: `msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`. +A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for; +add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install. + +**Update or uninstall asks for an old `.msi`, or stops with error 1612 or 1714?** Windows removes the installed version +with its own cached copy of that version's installer; if the copy is gone, it asks for the original file and stops +(an update then ends with error 1603). Download the release MSI of the version and build you have installed from +[all releases](https://github.com/storytold/lightcraft/releases) and run +`msiexec /fv "" /qb+ /norestart`, approving the permission prompt if Windows shows one. That restores +the cached copy; then retry the update or uninstall. Opening the MSI, or choosing Repair or Uninstall on it, may still +fail. Error 1603 has other causes too; if this doesn't help, open an issue with the error message. ### macOS diff --git a/README.zh-hans.md b/README.zh-hans.md index 2c6ca8191..fa782ba10 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -484,9 +484,11 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI | arm64(骁龙及其他 ARM PC) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | | x86(32 位) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | -安装程序和可执行文件都有代码签名。安装程序会询问安装位置(默认 -`C:\Program Files\LightCraft`;升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装, +安装程序和可执行文件都有代码签名。安装程序会询问为谁安装以及安装位置(默认仅为当前账户,安装到 +`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;选择“这台计算机上的所有人”则安装到 +`C:\Program Files\ArtCraft\LightCraft`(32 位版本为 `Program Files (x86)`);升级会留在你选择的文件夹),结束时有一页确认 LightCraft 已安装, 并可选择启动它。无人值守安装:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 +静默安装默认仅为当前用户安装。`INSTALLFOLDER` 只决定安装文件夹,不决定为谁安装;若要为所有用户安装,请在提升权限的终端或部署工具中加上 `ALLUSERS=1`。 ### macOS diff --git a/README.zh-hant.md b/README.zh-hant.md index ca624ce68..07ac908d2 100644 --- a/README.zh-hant.md +++ b/README.zh-hant.md @@ -514,9 +514,11 @@ egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI | arm64(Snapdragon 及其他 ARM PC) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | | x86(32 位元) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | -安裝程式與執行檔都有程式碼簽章。安裝程式會詢問安裝位置(預設為 -`C:\Program Files\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝, +安裝程式與執行檔都有程式碼簽章。安裝程式會詢問為誰安裝以及安裝位置(預設僅為目前帳戶,安裝到 +`%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`;選擇「這台電腦上的所有人」則安裝到 +`C:\Program Files\ArtCraft\LightCraft`(32 位元版本為 `Program Files (x86)`);升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝, 並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。 +無訊息安裝預設僅為目前使用者安裝。`INSTALLFOLDER` 只決定安裝資料夾,不決定為誰安裝;若要為所有使用者安裝,請在提升權限的終端機或部署工具中加上 `ALLUSERS=1`。 ### macOS diff --git a/ROADMAP.md b/ROADMAP.md index 6802f9234..daad94027 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-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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the lens profile database priority links its tracking issue #329; 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 @@ -108,7 +108,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ 4. ~~Full-resolution zoom~~ (done 2026-10-12) 5. AI masks: Subject / Sky / People (40–70 h + model decision) 6. Compressed ORF and HE NEF (28–55 h) -7. Lens profile database of our own (40–80 h) +7. Lens profile database of our own (40–80 h, #329) 8. Tests in CI on macOS, Windows and Linux (6–12 h) 9. Per-model raw verification (20–35 h) @@ -191,6 +191,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Priority 7 (lens profile database) links its tracking issue #329; headline numbers unchanged | | 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 | diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 6e9e85596..bd5215e4d 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -175,6 +175,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen let (backup_backend, backup_files) = (backend.clone(), files); let restore_backend = backend.clone(); Services { + local_places: None, picker: None, pick_display_profile: None, screen_profile: None, diff --git a/apps/lightcraft/src/gpu_crash.rs b/apps/lightcraft/src/gpu_crash.rs index 1656d88e3..3b7422315 100644 --- a/apps/lightcraft/src/gpu_crash.rs +++ b/apps/lightcraft/src/gpu_crash.rs @@ -1,5 +1,5 @@ use super::prefs::config_dir; -use super::*; +use super::window_gpu; /// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12 /// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 7b572fa0e..f87f0dc72 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -136,7 +136,7 @@ const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); fn main() -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::LIGHTCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&lightcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so every start-up record is kept for the log file (`logging`). diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index a5a14e208..d25b56566 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -295,12 +295,20 @@ fn label_text(s: &str) -> String { lightcraft_ui_egui::i18n::tr(s).replace('&', "&&") } -/// 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)) { - nodes.pop(); +/// Drop the separators left behind when an item moved to the app menu: a leading one, a trailing +/// one and the second of two in a row. +fn tidy_separators(nodes: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(nodes.len()); + for node in nodes { + if node == MenuNode::Separator && out.last().is_none_or(|last| *last == MenuNode::Separator) { + continue; + } + out.push(node); } - nodes + while out.last() == Some(&MenuNode::Separator) { + out.pop(); + } + out } impl NativeMenu { @@ -570,6 +578,24 @@ impl NativeMenu { mod tests { use super::*; + /// An item that moves to the app menu (Settings…) can sit between two separators, or open or + /// close the menu's list: the rows left behind keep at most one separator between them. + #[test] + fn rows_left_after_a_move_keep_single_separators() { + let item = |id: &str| MenuNode::Item { + id: id.to_string(), + label: id.to_string(), + shortcut: None, + enabled: true, + checked: None, + params: Value::Null, + }; + let sep = || MenuNode::Separator; + let tidy = tidy_separators(vec![sep(), item("a"), sep(), sep(), item("b"), sep(), sep()]); + assert_eq!(tidy, vec![item("a"), sep(), item("b")]); + assert_eq!(tidy_separators(vec![sep(), sep()]), Vec::::new()); + } + /// The menu model's titles stay English, so only the language can tell the native menu to /// rebuild its titles (and the macOS app menu): every language switch — also between two CJK /// languages — must change the structure key. diff --git a/apps/lightcraft/src/prefs.rs b/apps/lightcraft/src/prefs.rs index 0800e6f3e..5701bf161 100644 --- a/apps/lightcraft/src/prefs.rs +++ b/apps/lightcraft/src/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LibraryProblem, LightcraftApp, UiState}; /// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's /// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. diff --git a/apps/lightcraft/src/session.rs b/apps/lightcraft/src/session.rs index ce3ab95b7..b886b472f 100644 --- a/apps/lightcraft/src/session.rs +++ b/apps/lightcraft/src/session.rs @@ -11,6 +11,7 @@ pub(crate) fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std:: #[cfg(any(target_os = "linux", target_os = "freebsd"))] let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc)); Services { + local_places: None, // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread // stopped the window answering the compositor, which then reported the app as hung. picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { diff --git a/clippy.toml b/clippy.toml index 287e14edc..3522a15b6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index 95e58421a..7518acd38 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -74,3 +74,6 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 27273d5c4..b12e18803 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -20,6 +20,10 @@ log = { workspace = true } chrono = { workspace = true } web-time = { workspace = true } +[target.'cfg(windows)'.dependencies] +winapi-util = { workspace = true } + [dev-dependencies] lightcraft-geom = { workspace = true } +lightcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/examples/persist_bench.rs b/crates/catalog/examples/persist_bench.rs index 693e862d0..1fc05588c 100644 --- a/crates/catalog/examples/persist_bench.rs +++ b/crates/catalog/examples/persist_bench.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use std::time::Instant; -use lightcraft_catalog::*; +use lightcraft_catalog::{Catalog, DEFAULT_FORGET_DAYS, FsStore, HistoryStep, Journal, MediaKind, Op, Photo, PhotoId, Source, folder_of}; use lightcraft_develop::DevelopSettings; fn ms(t: Instant) -> f64 { diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index ff94268a8..94cfa1764 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -20,7 +20,7 @@ pub enum RatingOp { #[serde(default, rename_all = "camelCase")] pub struct Filter { /// Free-text search: every token must match filename, title, caption, keywords, camera, lens, - /// location or format. Tokens like `rating:3`, `flag:pick`, `iso:>800`, `camera:x2` are fielded. + /// location or format. Tokens like `rating:3`, `rating:>=3`, `flag:pick`, `iso:>800`, `camera:x2` are fielded. pub text: String, pub rating: u8, pub rating_op: RatingOp, @@ -179,23 +179,25 @@ fn token_matches(p: &Photo, tok: &str) -> bool { // a token that starts with a quote is a phrase, even with a colon in it if let Some((field, quoted)) = t.split_once(':').filter(|_| !t.starts_with('"')) { let val = unquote(quoted); - let num = |s: &str| -> Option<(char, f64)> { - let (op, rest) = match s.chars().next()? { - c @ ('>' | '<' | '=') => (c, &s[1..]), - _ => ('=', s), - }; - rest.parse().ok().map(|v| (op, v)) + // `>=` / `<=` before `>` / `<`: otherwise `rating:>=3` reads `=3` as the number and matches nothing + let num = |s: &str| -> Option<(&str, f64)> { + let op = [">=", "<=", ">", "<", "="].into_iter().find(|op| s.starts_with(op)).unwrap_or(""); + s.get(op.len()..)?.parse().ok().map(|v| (op, v)) }; let cmp = |x: f64, s: &str| match num(s) { - Some(('>', v)) => x > v, - Some(('<', v)) => x < v, + Some((">", v)) => x > v, + Some(("<", v)) => x < v, + Some((">=", v)) => x >= v, + Some(("<=", v)) => x <= v, Some((_, v)) => (x - v).abs() < 1e-9, None => false, }; // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8. let cmp32 = |x: f32, s: &str| match num(s) { - Some(('>', v)) => x > v as f32, - Some(('<', v)) => x < v as f32, + Some((">", v)) => x > v as f32, + Some(("<", v)) => x < v as f32, + Some((">=", v)) => x >= v as f32, + Some(("<=", v)) => x <= v as f32, Some((_, v)) => x == v as f32, None => false, }; diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index fa067de0f..2ed0acb5e 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -23,6 +23,7 @@ //! replacing anything** (a hard link, which fails on a taken name; where the volume has no hard //! links, an exclusive create that claims the name, then a rename over that placeholder). //! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix, +//! volume serial number + file index on Windows, //! resolved paths elsewhere), for "is this target an original?" checks. use std::cell::Cell; @@ -267,26 +268,75 @@ fn same_content(path: &Path, data: &[u8]) -> io::Result { } } -/// Do `a` and `b` name the same existing file (through links, `..`, case-insensitive names)? +/// Do `a` and `b` name the same existing file (through hard links, symlinks, `..`, +/// case-insensitive names)? pub fn same_file(a: &Path, b: &Path) -> bool { - let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else { return false }; + compare_files(a, b, false).unwrap_or(false) +} + +/// [`same_file`] for a check that must not let an unknown identity pass: `Err` when the metadata +/// of either path cannot be read for a reason other than the path not existing (for example a +/// folder it cannot enter), or when both paths exist with the same length but the file identity +/// of either cannot be read (on Windows, when either file cannot be opened or reports file index 0). A path that does not +/// exist is still `Ok(false)`. +pub fn same_file_strict(a: &Path, b: &Path) -> io::Result { + compare_files(a, b, true) +} + +fn compare_files(a: &Path, b: &Path, strict: bool) -> io::Result { + let missing = |e: &io::Error| matches!(e.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory); + let (ma, mb) = match (fs::metadata(a), fs::metadata(b)) { + (Ok(ma), Ok(mb)) => (ma, mb), + (Err(e), _) | (_, Err(e)) if strict && !missing(&e) => return Err(e), + _ => return Ok(false), + }; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; - ma.dev() == mb.dev() && ma.ino() == mb.ino() + Ok(ma.dev() == mb.dev() && ma.ino() == mb.ino()) } #[cfg(not(unix))] { if ma.len() != mb.len() { - return false; + return Ok(false); } - match (fs::canonicalize(a), fs::canonicalize(b)) { + // A hard link has its own path but shares the volume serial number and file index with + // its original. A file index of 0 produces an error in strict mode. Lenient mode compares resolved paths. + // On ReFS two files can share the 64-bit file index, so the lengths are compared first. + #[cfg(windows)] + { + match file_ids(a, b).and_then(|(ia, ib)| same_file_id(ia, ib, strict)) { + Ok(true) => return Ok(true), + Ok(_) => {} + Err(e) if strict => return Err(e), + Err(_) => {} + } + } + Ok(match (fs::canonicalize(a), fs::canonicalize(b)) { (Ok(x), Ok(y)) => x == y || x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(), _ => false, - } + }) } } +/// The volume serial number and file index of `a` and `b`, read while both files are open. +#[cfg(windows)] +fn file_ids(a: &Path, b: &Path) -> io::Result<((u64, u64), (u64, u64))> { + use winapi_util::{Handle, file::information}; + let (ha, hb) = (Handle::from_path_any(a)?, Handle::from_path_any(b)?); + let (ia, ib) = (information(&ha)?, information(&hb)?); + Ok(((ia.volume_serial_number(), ia.file_index()), (ib.volume_serial_number(), ib.file_index()))) +} + +/// The comparison uses each file's volume serial number and file index. Strict mode returns an error for file index 0. +#[cfg(any(windows, test))] +fn same_file_id(a: (u64, u64), b: (u64, u64), strict: bool) -> io::Result { + if strict && (a.1 == 0 || b.1 == 0) { + return Err(io::Error::other("file identity is unavailable")); + } + Ok(a.1 != 0 && b.1 != 0 && a == b) +} + #[cfg(test)] mod tests { use super::*; @@ -397,6 +447,29 @@ mod tests { let _ = fs::remove_dir_all(&d); } + #[test] + fn same_file_sees_a_hard_link_under_another_name() { + let d = temp("same-link"); + let p = d.join("IMG_1.jpg"); + fs::write(&p, b"original").unwrap(); + let linked = d.join("linked.png"); + match fs::hard_link(&p, &linked) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => panic!("{}: {e}", linked.display()), + // no hard links on this volume (FAT/exFAT, some shares), as in `publish_new` + Err(e) => { + let _ = fs::remove_dir_all(&d); + lightcraft_testkit::skip!(System, "no hard links in {}: {e}", d.display()); + return; + } + } + fs::write(d.join("copy.png"), b"original").unwrap(); + assert!(same_file(&p, &linked), "a hard link is the same file"); + assert!(same_file(&linked, &p)); + assert!(!same_file(&p, &d.join("copy.png")), "a copy with the same content and length is another file"); + let _ = fs::remove_dir_all(&d); + } + #[test] fn same_file_sees_through_other_spellings() { let d = temp("same"); @@ -409,4 +482,33 @@ mod tests { assert!(!same_file(&p, &d.join("missing.jpg"))); let _ = fs::remove_dir_all(&d); } + + #[test] + fn strict_comparison_reports_a_path_it_cannot_query() { + let d = temp("strict-query"); + let p = d.join("x.jpg"); + fs::write(&p, b"x").unwrap(); + // A NUL byte makes the metadata lookup fail with an error other than "not found" on + // every platform, as a folder the process cannot enter does. + let unreadable = Path::new("IMG_1\0.jpg"); + assert!(same_file_strict(unreadable, &p).is_err()); + assert!(same_file_strict(&p, unreadable).is_err()); + assert!(!same_file(unreadable, &p), "the lenient comparison still answers false"); + assert!(!same_file_strict(&d.join("missing.jpg"), &p).unwrap(), "a missing original is another file"); + assert!(!same_file_strict(&p.join("under-a-file.jpg"), &p).unwrap()); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn a_zero_file_index_is_not_a_file_identity() { + for strict in [false, true] { + assert!(same_file_id((7, 42), (7, 42), strict).unwrap()); + assert!(!same_file_id((7, 42), (7, 43), strict).unwrap()); + assert!(!same_file_id((7, 42), (8, 42), strict).unwrap()); + } + for (a, b) in [((7, 0), (7, 0)), ((0, 0), (0, 0)), ((7, 42), (7, 0)), ((7, 0), (7, 42))] { + assert!(same_file_id(a, b, true).is_err(), "strict comparison must reject an unavailable identity: {a:?}, {b:?}"); + assert!(!same_file_id(a, b, false).unwrap(), "lenient comparison can fall back to resolved paths"); + } + } } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 579175086..32392469c 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -163,6 +163,42 @@ fn search_tokens_match_f32_aperture_and_focal() { assert!(q("f:>2.8").is_empty(), "f:>2.8"); assert!(q("focal:<5.6").is_empty(), "focal:<5.6"); assert_eq!(q("f:>2.7"), vec![a], "f:>2.7"); + assert_eq!(q("f:>=2.8"), vec![a], "f:>=2.8 counts the photo's own value"); + assert_eq!(q("focal:<=5.6"), vec![a], "focal:<=5.6"); + assert!(q("f:>=2.9").is_empty(), "f:>=2.9"); + assert!(q("focal:<=5.5").is_empty(), "focal:<=5.5"); +} + +/// `>=` and `<=` in a fielded token are the inclusive comparisons. They used to read the `=` as +/// part of the number, so `rating:>=3` or `iso:<=400` silently matched no photo at all. +#[test] +fn search_tokens_compare_inclusively() { + let mut c = Catalog::new(); + let (two, three, four) = ( + photo(&mut c, "two.jpg", "2026-05-01T10:00:00"), + photo(&mut c, "three.jpg", "2026-05-02T10:00:00"), + photo(&mut c, "four.jpg", "2026-05-03T10:00:00"), + ); + for (id, rating, iso) in [(two, 2, 200), (three, 3, 400), (four, 4, 800)] { + c.apply(Op::SetRating { id, rating }).unwrap(); + let mut m = c.photo(id).unwrap().meta.clone(); + m.iso = Some(iso); + c.apply(Op::SetMeta { id, meta: Box::new(m) }).unwrap(); + } + let q = |t: &str| { + let mut ids = c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default()); + ids.sort(); + ids + }; + assert_eq!(q("rating:>=3"), vec![three, four]); + assert_eq!(q("stars:<=3"), vec![two, three]); + assert_eq!(q("iso:>=400"), vec![three, four]); + assert_eq!(q("iso:<=400"), vec![two, three]); + assert_eq!(q("rating:>3"), vec![four], "strict comparisons are unchanged"); + assert_eq!(q("rating:=3"), vec![three]); + assert_eq!(q("rating:3"), vec![three]); + assert!(q("rating:>=").is_empty(), "an operator without a number matches nothing"); + assert!(q("rating:=>3").is_empty(), "not an operator"); } /// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an @@ -904,17 +940,9 @@ fn search_text_finds_people_and_quoted_values() { /// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be /// said photo by photo. #[test] -fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() { +fn photo_ops_record_touched_ids_and_changed_aspects() { let mut c = Catalog::default(); - let photo = |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) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg")); + let (a, b) = (photo(&mut c, "a.jpg", "2026-01-01T10:00:00"), photo(&mut c, "b.jpg", "2026-01-01T10:00:00")); let start = c.revision; assert_eq!(c.touched_since(start), Some(vec![])); assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); @@ -940,28 +968,25 @@ fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() assert_eq!(c.touched_since(before), None); assert_eq!(c.aspects_since(start), None); assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); - // a revision that is not this catalog's past - assert_eq!(c.touched_since(c.revision + 5), None); - // a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after - // the next op nothing of before it - let old = c.revision; +} + +#[test] +fn history_handles_loaded_revisions_failed_batches_and_retention() { + let mut c = Catalog::default(); + let a = photo(&mut c, "a.jpg", "2026-01-01T10:00:00"); + let album = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap(); + // 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 + let before = c.revision; c.revision = 7 << 32; - assert_eq!(c.touched_since(old), None); + assert_eq!(c.touched_since(before), None); assert_eq!(c.aspects_since(c.revision), None); let loaded = c.revision; 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 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_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 + assert_eq!(c.touched_since(before), None); + let before = c.revision; 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))); diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 725980e53..8aff31c5c 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -227,7 +227,7 @@ pub enum TiffCompression { /// Encode TIFF: 8/16-bit integer or 32-bit float, gray/RGB with optional (unassociated) alpha. /// ICC and XMP are embedded; EXIF is not (write it through the TIFF writer in `lightcraft-tiff`). pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &EncodeMeta) -> Result> { - use tiff::encoder::{Compression, DeflateLevel, TiffEncoder, colortype}; + use tiff::encoder::{Compression, DeflateLevel, TiffEncoder}; use tiff::tags::Predictor; img.validate()?; let e = |e: tiff::TiffError| Error::Encode(e.to_string()); @@ -243,54 +243,92 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod if !float && matches!(compression, TiffCompression::Lzw | TiffCompression::Deflate) { Predictor::Horizontal } else { Predictor::None }; let mut enc = TiffEncoder::new(&mut cur).map_err(e)?.with_compression(comp).with_predictor(predictor); let (w, h) = (img.width, img.height); - let n = w as usize * h as usize; - // Gray + alpha is stored as RGBA (the encoder has no GrayA colour type). - let ch = img.channels as usize; - - macro_rules! write { - ($ct:ty, $data:expr, $alpha:expr) => {{ - let mut im = enc.new_image::<$ct>(w, h).map_err(e)?; - if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { - im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 }); - } - if let Some(icc) = meta.icc { - im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?; - } - if let Some(xmp) = meta.xmp { - im.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(e)?; - } - if $alpha { - im.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(e)?; - } - im.write_data($data).map_err(e)?; - }}; - } - fn expand_ga(s: &[T], n: usize) -> Vec { - s.as_chunks::<2>().0.iter().take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect() - } + let pixels = w as usize * h as usize; match img.samples { - Samples::U8(s) => match ch { - 1 => write!(colortype::Gray8, &s[..n], false), - 2 => write!(colortype::RGBA8, &expand_ga(s, n), true), - 3 => write!(colortype::RGB8, &s[..n * 3], false), - _ => write!(colortype::RGBA8, &s[..n * 4], true), - }, - Samples::U16(s) => match ch { - 1 => write!(colortype::Gray16, &s[..n], false), - 2 => write!(colortype::RGBA16, &expand_ga(s, n), true), - 3 => write!(colortype::RGB16, &s[..n * 3], false), - _ => write!(colortype::RGBA16, &s[..n * 4], true), - }, - Samples::F32(s) => match ch { - 1 => write!(colortype::Gray32Float, &s[..n], false), - 2 => write!(colortype::RGBA32Float, &expand_ga(s, n), true), - 3 => write!(colortype::RGB32Float, &s[..n * 3], false), - _ => write!(colortype::RGBA32Float, &s[..n * 4], true), - }, + Samples::U8(samples) => write_tiff_u8(&mut enc, samples, pixels, img.channels as usize, img, meta)?, + Samples::U16(samples) => write_tiff_u16(&mut enc, samples, pixels, img.channels as usize, img, meta)?, + Samples::F32(samples) => write_tiff_f32(&mut enc, samples, pixels, img.channels as usize, img, meta)?, } Ok(cur.into_inner()) } +macro_rules! write_tiff_image { + ($encoder:expr, $image:expr, $meta:expr, $color:ty, $data:expr, $alpha:expr) => {{ + let mut image = $encoder.new_image::<$color>($image.width, $image.height).map_err(|e| Error::Encode(e.to_string()))?; + if let Some(ppi) = $meta.ppi.filter(|ppi| *ppi > 0) { + image.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: ppi as u32, d: 1 }); + } + if let Some(icc) = $meta.icc { + image.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(|e| Error::Encode(e.to_string()))?; + } + if let Some(xmp) = $meta.xmp { + image.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(|e| Error::Encode(e.to_string()))?; + } + if $alpha { + image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(|e| Error::Encode(e.to_string()))?; + } + image.write_data($data).map_err(|e| Error::Encode(e.to_string()))?; + }}; +} + +fn expanded_gray_alpha(samples: &[T], pixels: usize) -> Vec { + samples.as_chunks::<2>().0.iter().take(pixels).flat_map(|channel| [channel[0], channel[0], channel[0], channel[1]]).collect() +} + +fn write_tiff_u8( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[u8], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray8, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB8, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA8, &samples[..pixels * 4], true), + } + Ok(()) +} + +fn write_tiff_u16( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[u16], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray16, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB16, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA16, &samples[..pixels * 4], true), + } + Ok(()) +} + +fn write_tiff_f32( + encoder: &mut tiff::encoder::TiffEncoder, + samples: &[f32], + pixels: usize, + channels: usize, + image: &EncodeImage, + meta: &EncodeMeta, +) -> Result<()> { + use tiff::encoder::colortype; + match channels { + 1 => write_tiff_image!(encoder, image, meta, colortype::Gray32Float, &samples[..pixels], false), + 2 => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &expanded_gray_alpha(samples, pixels), true), + 3 => write_tiff_image!(encoder, image, meta, colortype::RGB32Float, &samples[..pixels * 3], false), + _ => write_tiff_image!(encoder, image, meta, colortype::RGBA32Float, &samples[..pixels * 4], true), + } + Ok(()) +} + /// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image /// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded. pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result> { diff --git a/crates/codecs/src/jpeg_par.rs b/crates/codecs/src/jpeg_par.rs index c8d9e5023..763b73afa 100644 --- a/crates/codecs/src/jpeg_par.rs +++ b/crates/codecs/src/jpeg_par.rs @@ -255,9 +255,20 @@ fn fdct_quant(block: &[f32; 64], recip: &[f32; 64], out: &mut [i16; 64]) { aan_1d(&mut d, c, 8); } for i in 0..64 { - out[i] = (d[i] * recip[i]).round() as i16; + out[i] = round_i16(d[i] * recip[i]); } } +/// `x.round() as i16` (half away from zero, saturating, NaN to 0) for every `x` (checked for all of them), in plain +/// arithmetic: without SSE4.1 `f32::round` is a libm call, one per coefficient, that keeps the loop from vectorising. +#[inline(always)] +fn round_i16(x: f32) -> i16 { + // (below 2^24 the truncation is exact and so is the remainder) + let c = x.clamp(-65536.0, 65536.0); + let t = c as i32; + let f = c - t as f32; + let r = t + i32::from(f >= 0.5) - i32::from(f <= -0.5); + r.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16 +} #[inline] fn magnitude(v: i32) -> (u32, u32) { @@ -395,31 +406,30 @@ fn encode_with( (true, _) | (false, ChromaSubsampling::S444) => (1usize, 1usize), (false, _) => (2, 2), }; - // colour conversion (JFIF YCbCr, full range, level-shifted) in one pass; chroma averaged 2×2 for 4:2:0 - let n = width * height; #[cfg(feature = "parallel")] use rayon::prelude::*; - // 4:4:4: Y, Cb, Cr at full resolution in one pass; 4:2:0: Y here, chroma below straight from 2×2 RGB means + // colour conversion (JFIF YCbCr, full range, level-shifted) of the pixel rows `rows`, one band's worth at a + // time (in the band's task: the planes of the whole image would be ~290 MB at 24 MP, written and read back + // once): 4:4:4 Y, Cb, Cr at full resolution in one pass; 4:2:0 Y, and chroma (rows `crows` of the + // half-size planes) straight from 2×2 RGB means (conversion is linear: average first, then convert once) let ncomp = if gray || hs == 2 { 1 } else { 3 }; - let mut full: Vec> = (0..ncomp).map(|_| vec![0f32; n]).collect(); - { - let rows: Vec<(usize, Vec<&mut [f32]>)> = { - let mut its: Vec<_> = full.iter_mut().map(|p| p.chunks_mut(width)).collect(); - (0..height).map_while(|y| Some((y, its.iter_mut().map(|it| it.next()).collect::>()?))).collect() - }; - let conv = |(y, mut out): (usize, Vec<&mut [f32]>)| { + let (cw, ch) = (width.div_ceil(2), height.div_ceil(2)); + let convert = |rows: std::ops::Range, crows: std::ops::Range| -> Vec { + let mut full: Vec> = (0..ncomp).map(|_| vec![0f32; rows.len() * width]).collect(); + for (k, y) in rows.clone().enumerate() { let src = &data[y * width * channels..(y + 1) * width * channels]; + let mut out: Vec<&mut [f32]> = full.iter_mut().map(|p| &mut p[k * width..(k + 1) * width]).collect(); if gray { for (o, p) in out[0].iter_mut().zip(src.chunks_exact(channels)) { *o = p[0] as f32 - 128.0; } - return; + continue; } if out.len() == 1 { for (o, p) in out[0].iter_mut().zip(src.chunks_exact(channels)) { *o = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32 - 128.0; } - return; + continue; } for (x, p) in src.chunks_exact(channels).enumerate() { let (r, g, b) = (p[0] as f32, p[1] as f32, p[2] as f32); @@ -427,37 +437,26 @@ fn encode_with( out[1][x] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; out[2][x] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; } - }; - #[cfg(feature = "parallel")] - rows.into_par_iter().for_each(conv); - #[cfg(not(feature = "parallel"))] - rows.into_iter().for_each(conv); - } - let mut it = full.into_iter(); - let mut planes = vec![Plane { w: width, h: height, data: it.next().unwrap_or_default() }]; - planes.extend(it.map(|c| Plane { w: width, h: height, data: c })); - if !gray && hs == 2 { - // conversion is linear: average RGB over 2×2 first, then convert once - let (cw, ch) = (width.div_ceil(2), height.div_ceil(2)); - let (mut cb, mut cr) = (vec![0f32; cw * ch], vec![0f32; cw * ch]); - let down = |(cy, (ob, or)): (usize, (&mut [f32], &mut [f32]))| { - let (y0, y1) = (2 * cy, (2 * cy + 1).min(height - 1)); - let (r0, r1) = (&data[y0 * width * channels..], &data[y1 * width * channels..]); - for cx in 0..cw { - let (x0, x1) = (2 * cx * channels, (2 * cx + 1).min(width - 1) * channels); - let s = |k: usize| (r0[x0 + k] as f32 + r0[x1 + k] as f32 + r1[x0 + k] as f32 + r1[x1 + k] as f32) * 0.25; - let (r, g, b) = (s(0), s(1), s(2)); - ob[cx] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; - or[cx] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; + } + let mut planes: Vec = full.into_iter().map(|data| Plane { w: width, h: rows.len(), data }).collect(); + if !gray && hs == 2 { + let (mut cb, mut cr) = (vec![0f32; cw * crows.len()], vec![0f32; cw * crows.len()]); + for (cy, (ob, or)) in crows.clone().zip(cb.chunks_mut(cw).zip(cr.chunks_mut(cw))) { + let (y0, y1) = (2 * cy, (2 * cy + 1).min(height - 1)); + let (r0, r1) = (&data[y0 * width * channels..], &data[y1 * width * channels..]); + for cx in 0..cw { + let (x0, x1) = (2 * cx * channels, (2 * cx + 1).min(width - 1) * channels); + let s = |k: usize| (r0[x0 + k] as f32 + r0[x1 + k] as f32 + r1[x0 + k] as f32 + r1[x1 + k] as f32) * 0.25; + let (r, g, b) = (s(0), s(1), s(2)); + ob[cx] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; + or[cx] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; + } } - }; - #[cfg(feature = "parallel")] - cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw)).enumerate().for_each(down); - #[cfg(not(feature = "parallel"))] - cb.chunks_mut(cw).zip(cr.chunks_mut(cw)).enumerate().for_each(down); - planes.push(Plane { w: cw, h: ch, data: cb }); - planes.push(Plane { w: cw, h: ch, data: cr }); - } + planes.push(Plane { w: cw, h: crows.len(), data: cb }); + planes.push(Plane { w: cw, h: crows.len(), data: cr }); + } + planes + }; let ql = scaled_quant(&Q_LUMA, quality); let qc = scaled_quant(&Q_CHROMA, quality); @@ -473,7 +472,12 @@ fn encode_with( let (mut blk, mut q) = ([0f32; 64], [0i16; 64]); let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]); let mut pred = [0i32; 3]; - for mcu_y in band * rows_per_band..((band + 1) * rows_per_band).min(my) { + let (m0, m1) = (band * rows_per_band, ((band + 1) * rows_per_band).min(my)); + // the band's planes, its MCU row `m0` at their top (blocks past the image's bottom repeat its last row, + // which is the band's last row then) + let planes = convert(m0 * mcu_h..(m1 * mcu_h).min(height), m0 * 8..(m1 * 8).min(ch)); + for mcu_y in m0..m1 { + let mcu_y = mcu_y - m0; for mcu_x in 0..mx { for vy in 0..vs { for hx in 0..hs { @@ -497,7 +501,6 @@ fn encode_with( let tokenized: Vec<(Vec, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect(); #[cfg(not(feature = "parallel"))] let tokenized: Vec<(Vec, [Freq; 4])> = bands.iter().map(tokenize_band).collect(); - drop(planes); // the tables: DC luma, AC luma, DC chroma, AC chroma let tables: [([u8; 16], Vec); 4] = if optimize { let mut total = [[0u64; 256]; 4]; @@ -746,6 +749,34 @@ mod tests { assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap()); } + /// The bytes written for 4:4:4, 4:2:0, grayscale and RGBA input at a size cut into many bands, with partial MCUs + /// at the right and bottom edges, pinned: each band converts its own rows to YCbCr, which must not change a bit. + #[test] + fn encoded_bytes_are_pinned() { + let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); + let (w, h) = (1203, 1037); + let rgb = test_rgb(w, h); + let gray: Vec = rgb.as_chunks::<3>().0.iter().map(|p| p[1]).collect(); + let rgba: Vec = rgb.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + let got = [ + fnv(&encode(&rgb, w, h, 3, 90, ChromaSubsampling::S444, &[])), + fnv(&encode(&rgb, w, h, 3, 75, ChromaSubsampling::S420, &[])), + fnv(&encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[])), + fnv(&encode(&rgba, w, h, 4, 85, ChromaSubsampling::S444, &[])), + ]; + assert_eq!(got, PINNED, "{got:#x?}"); + } + /// [`round_i16`] against `f32::round` at the halves, the ends of `i16`, NaN and infinities, and a spread of every + /// magnitude (all 2^32 inputs were compared once, offline). + #[test] + fn round_i16_is_round_as_i16() { + let special = [0.5f32, -0.5, 1.5, -1.5, 2.5, -2.5, 0.499_999_97, -0.499_999_97, 32767.5, -32768.5, 32766.5, 1e9, -1e9]; + let all = special.into_iter().chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]).chain((0..=u32::MAX).step_by(9973).map(f32::from_bits)); + for x in all { + assert_eq!(round_i16(x), x.round() as i16, "{x:e}"); + } + } + const PINNED: [u64; 4] = [0x52ba_772a_589c_1430, 0x013f_8364_821f_1dde, 0xb2a3_b5e5_aef3_7402, 0x08df_d6dc_aa2f_1c78]; #[test] fn grayscale_and_quality_monotone() { let (w, h) = (300, 200); diff --git a/crates/color/src/perceptual.rs b/crates/color/src/perceptual.rs index 78824321c..fb5b89b75 100644 --- a/crates/color/src/perceptual.rs +++ b/crates/color/src/perceptual.rs @@ -132,7 +132,9 @@ fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { } else { (c[0] - c[1]) / d + 4.0 }; - h * 60.0 + let deg = h * 60.0; + // rem_euclid can round up to exactly 6.0 in f32; keep the range [0, 360). + if deg >= 360.0 { 0.0 } else { deg } } #[cfg(test)] @@ -173,4 +175,11 @@ mod tests { } assert_eq!(rgb_to_hsv([0.0, 1.0, 0.0])[0], 120.0); } + + #[test] + fn hue_below_360_for_tiny_blue() { + let c = [1.0, 0.0, 1e-9]; + assert!(rgb_to_hsv(c)[0] < 360.0, "hsv hue {}", rgb_to_hsv(c)[0]); + assert!(rgb_to_hsl(c)[0] < 360.0, "hsl hue {}", rgb_to_hsl(c)[0]); + } } diff --git a/crates/color/src/transfer.rs b/crates/color/src/transfer.rs index 8a7e4fdbf..72ff76fc7 100644 --- a/crates/color/src/transfer.rs +++ b/crates/color/src/transfer.rs @@ -49,6 +49,7 @@ pub fn hlg_encode(e: f64) -> f64 { } pub fn hlg_decode(s: f64) -> f64 { + let s = s.max(0.0); if s <= 0.5 { s * s / 3.0 } else { (((s - HLG_C) / HLG_A).exp() + HLG_B) / 12.0 } } @@ -112,6 +113,11 @@ mod tests { } } + #[test] + fn hlg_decode_clamps_negative() { + assert_eq!(hlg_decode(-0.5), 0.0); + } + #[test] fn pq_hlg_roundtrip() { for i in 0..=50 { diff --git a/crates/denoise-core/src/bayer.rs b/crates/denoise-core/src/bayer.rs index a4a00d947..365bec436 100644 --- a/crates/denoise-core/src/bayer.rs +++ b/crates/denoise-core/src/bayer.rs @@ -14,6 +14,14 @@ pub struct Layout { pub dy: usize, } +/// The mosaic dimensions and cell origin for one square denoiser input tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TileRegion { + pub mosaic_size: (usize, usize), + pub cell_origin: (isize, isize), + pub tile_size: usize, +} + impl Layout { /// The layout of a 2 × 2 colour pattern, row-major, colours 0 = red, 1 = green, 2 = blue; `None` when /// it is not a Bayer cell (one red, one blue, the greens on the other diagonal). @@ -65,7 +73,10 @@ pub fn reflect(i: isize, n: usize) -> usize { /// Pack the `tile × tile` cells whose top-left cell is `(cx0, cy0)` into `out` as four planes `[R, G1, G2, B]` /// (`4 · tile²` values, NCHW). G1 is the green beside the red sample, G2 the green under it. Samples outside /// the picture are mirrored. Returns `false` (and writes nothing) when the sizes do not fit. -pub fn pack_tile(mosaic: &[f32], width: usize, height: usize, layout: Layout, cx0: isize, cy0: isize, tile: usize, out: &mut [f32]) -> bool { +pub fn pack_tile(mosaic: &[f32], layout: Layout, region: TileRegion, out: &mut [f32]) -> bool { + let (width, height) = region.mosaic_size; + let (cx0, cy0) = region.cell_origin; + let tile = region.tile_size; let Some(plane) = tile.checked_mul(tile) else { return false }; let Some(values) = plane.checked_mul(4) else { return false }; if width == 0 @@ -113,7 +124,7 @@ mod tests { let layout = Layout::from_cell([0, 1, 1, 2]).unwrap(); let mut out = [7.0; 4]; for (w, h, tile, x) in [(usize::MAX, 2, 1, 0), (1, 1, usize::MAX, 0), (1, 1, 0, 0), (1, 1, 1, isize::MAX), (1, 1, 1, isize::MIN)] { - assert!(!pack_tile(&[0.5], w, h, layout, x, 0, tile, &mut out)); + assert!(!pack_tile(&[0.5], layout, TileRegion { mosaic_size: (w, h), cell_origin: (x, 0), tile_size: tile }, &mut out)); assert_eq!(out, [7.0; 4]); } for n in [1, 2, isize::MAX as usize, usize::MAX] { @@ -160,7 +171,7 @@ mod tests { let (nx, ny) = layout.cells(w, h); let tile = nx.max(ny); let mut out = vec![0.0; 4 * tile * tile]; - assert!(pack_tile(&mosaic, w, h, layout, 0, 0, tile, &mut out)); + assert!(pack_tile(&mosaic, layout, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: tile }, &mut out)); let plane = tile * tile; for (p, want) in [10.0, 20.0, 20.0, 30.0].into_iter().enumerate() { assert!(out[p * plane..(p + 1) * plane].iter().all(|&v| v == want), "layout {cell:?} plane {p}"); @@ -174,7 +185,7 @@ mod tests { let (w, h) = (4, 4); let mosaic: Vec = (0..16).map(|i| i as f32).collect(); let mut out = vec![0.0; 4 * 4]; - assert!(pack_tile(&mosaic, w, h, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); + assert!(pack_tile(&mosaic, Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (w, h), cell_origin: (0, 0), tile_size: 2 }, &mut out)); // cell (0,0): R = site (0,0)=0, G1 = site (1,0)=1, G2 = site (0,1)=4, B = site (1,1)=5 assert_eq!([out[0], out[4], out[8], out[12]], [0.0, 1.0, 4.0, 5.0]); // cell (1,0): sites (2,0)=2, (3,0)=3, (2,1)=6, (3,1)=7 @@ -184,8 +195,8 @@ mod tests { #[test] fn packing_rejects_wrong_sizes() { let mut out = vec![0.0; 16]; - assert!(!pack_tile(&[0.0; 15], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); - assert!(!pack_tile(&[0.0; 16], 4, 4, Layout { dx: 0, dy: 0 }, 0, 0, 3, &mut out)); - assert!(!pack_tile(&[], 0, 0, Layout { dx: 0, dy: 0 }, 0, 0, 2, &mut out)); + assert!(!pack_tile(&[0.0; 15], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 2 }, &mut out)); + assert!(!pack_tile(&[0.0; 16], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (4, 4), cell_origin: (0, 0), tile_size: 3 }, &mut out)); + assert!(!pack_tile(&[], Layout { dx: 0, dy: 0 }, TileRegion { mosaic_size: (0, 0), cell_origin: (0, 0), tile_size: 2 }, &mut out)); } } diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs index 4314ed8c3..180587d5d 100644 --- a/crates/denoise-core/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use lightcraft_raster::Rgb32f; use rayon::prelude::*; -use crate::bayer::{Layout, pack_tile}; +use crate::bayer::{Layout, TileRegion, pack_tile}; use crate::manifest::Gain; use crate::tiles::{starts, weight}; @@ -139,7 +139,12 @@ pub fn denoise_bayer( .par_iter() .map(|&(x0, y0, bx, ax, by, ay)| { let mut input = allocate(4 * p.tile * p.tile, 0f32)?; - if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) { + if !pack_tile( + mosaic, + layout, + TileRegion { mosaic_size: (width, height), cell_origin: (x0 as isize, y0 as isize), tile_size: p.tile }, + &mut input, + ) { return Err(Error::Input("a tile could not be packed".into())); } let mut out = runner.run(&input)?; diff --git a/crates/engine/examples/export_bench.rs b/crates/engine/examples/export_bench.rs new file mode 100644 index 000000000..7fdb1c6c8 --- /dev/null +++ b/crates/engine/examples/export_bench.rs @@ -0,0 +1,81 @@ +//! Single-photo export benchmark (median and minimum of N runs): +//! `cargo run --release -p lightcraft-engine --example export_bench -- corpus/raw/arw-sony-a7m3-compressed.arw` +//! +//! Imports the files given and runs the export path the app, CLI and MCP share +//! ([`lightcraft_engine::export::export_photo`]: decode the original, render it full size, encode) +//! once per file per run, forgetting the decoded original between runs so each export reads and +//! decodes it like a batch export does. `N=9` runs (default), `OPT='{"format":"png"}'` for other +//! export options (JSON as `app.export` takes them), `LIGHTCRAFT_GPU=0` for the CPU pipeline, +//! `PHASES=1` to time render and encode separately, `OUT=dir` to write each file's export there +//! (for byte comparisons between builds). +use std::time::Instant; + +use lightcraft_engine::Session; +use lightcraft_engine::catalog::PhotoId; +use lightcraft_engine::export::{ExportOptions, encode_rendered, export_metadata, export_photo}; + +fn stats(mut v: Vec) -> (f64, f64) { + v.sort_by(f64::total_cmp); + (v[v.len() / 2], v[0]) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + assert!(!args.is_empty(), "usage: export_bench FILE..."); + let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(9).max(1); + let opt: serde_json::Value = + std::env::var("OPT").ok().map(|s| serde_json::from_str(&s).expect("OPT is JSON")).unwrap_or_else(|| serde_json::json!({})); + let o = ExportOptions::from_params(&opt).expect("export options"); + let mut s = Session::new().with_fs(); + let paths: Vec = args.iter().map(|p| std::fs::canonicalize(p).expect("input file").to_string_lossy().to_string()).collect(); + let r = s.execute("library.import", &serde_json::json!({"paths": paths})).expect("import"); + let ids: Vec = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(PhotoId).collect(); + assert_eq!(ids.len(), args.len(), "every file imports"); + println!("{} file(s), {:?}, {n} runs, gpu {}", ids.len(), o.format, lightcraft_engine::gpu::available()); + let mut times = Vec::new(); + for i in 0..=n { + let t = Instant::now(); + for (k, &id) in ids.iter().enumerate() { + let e = export_photo(&mut s, id, &o, k + 1).expect("export"); + if i == 0 + && let Some(dir) = std::env::var_os("OUT") + { + std::fs::write(std::path::Path::new(&dir).join(&e.file_name), &e.bytes).expect("write"); + println!(" {} {}×{} {} bytes", e.file_name, e.width, e.height, e.bytes.len()); + } + s.media.forget(id); + } + // run 0 warms up (GPU device, allocator, page cache) + if i > 0 { + times.push(t.elapsed().as_secs_f64() * 1e3); + } + } + let (med, min) = stats(times.clone()); + println!("export: median {med:.1} ms, min {min:.1} ms (runs: {})", times.iter().map(|t| format!("{t:.0}")).collect::>().join(" ")); + if std::env::var_os("PHASES").is_none() { + return; + } + let (mut render, mut encode) = (Vec::new(), Vec::new()); + for _ in 0..n { + let (mut tr, mut te) = (0.0, 0.0); + for &id in &ids { + let p = s.catalog.photo(id).expect("photo").clone(); + let (w, h) = lightcraft_engine::export::output_size(&p, &o); + let meta = export_metadata(&p, &s.catalog, &o); + let job = s.export_job(id, w, h, o.effective_space(), o.effective_depth()).expect("job"); + let t = Instant::now(); + let r = job.run().rendered.expect("render"); + tr += t.elapsed().as_secs_f64() * 1e3; + let t = Instant::now(); + std::hint::black_box(encode_rendered(&r, &o, meta.as_ref()).expect("encode")); + te += t.elapsed().as_secs_f64() * 1e3; + s.media.forget(id); + } + render.push(tr); + encode.push(te); + } + let (rm, rn) = stats(render); + let (em, en) = stats(encode); + println!(" decode+render: median {rm:.1} ms, min {rn:.1} ms"); + println!(" encode: median {em:.1} ms, min {en:.1} ms"); +} diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 3d7dbc445..54b6cfe82 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -91,107 +91,119 @@ fn diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> (u8, f64) (max, sum as f64 / (a.data.len() * 3).max(1) as f64) } +fn benchmark_jpeg(args: &[String], n: usize) { + // a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes + for path in args { + let bytes = std::fs::read(path).expect("read"); + for edge in [256u32, 512, 1600, 2560] { + let o = lightcraft_codecs::DecodeOptions::fit(edge, edge); + let d = lightcraft_codecs::decode(&bytes, o).expect("decode"); + println!( + " {path} fit {edge}: {}×{} {}", + d.image.width, + d.image.height, + best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode"))) + ); + } + } +} + +fn benchmark_load(args: &[String], n: usize, only: &str) { + // opening a photo in the loupe, step by step: what the app's loaders cost (per file) + for path in args { + let bytes = std::fs::read(path).expect("read"); + println!("{path}"); + if std::env::var("LOADONLY").is_ok() { + // only the preview load (for a profiler) + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + continue; + } + println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read")))); + println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); + let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); + if let lightcraft_raw::RawData::U16(d) = &raw.data { + let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64)); + println!(" decode checksum: {sum:016x}"); + } + if only == "load1" { + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + continue; + } + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { + println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99)))); + } + for edge in [2560usize, 1600] { + println!( + " {:<36} {}", + format!("embedded srgb, whole file ({edge}):"), + best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge))) + ); + let quick = lightcraft_engine::files::fs_preview_loader(); + println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge)))); + } + println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); + let (loader, _) = lightcraft_engine::files::fs_hooks(); + println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load")))); + } +} + +fn benchmark_source(args: &[String], n: usize) { + // opening a photo: where the time goes before the first sharp loupe (per file) + for path in args { + let bytes = std::fs::read(path).expect("read"); + println!("{path}"); + let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); + // as the loader does: lens corrections are applied by the pipeline, not in the raw develop + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); + println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); + println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); + let norm = raw.normalized().expect("norm"); + println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd)))); + if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { + println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99)))); + let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable"); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + println!( + " {:<36} {}", + "highlight reconstruct (binned):", + best(n, || { + let mut i = img.clone(); + lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99); + }) + ); + println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone()))); + println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box)))); + } + println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560)))); + for edge in [512usize, 2560, usize::MAX] { + println!( + " {:<36} {}", + format!("load_bytes({edge}):"), + best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load"))) + ); + } + } +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5); let only = std::env::var("ONLY").unwrap_or_default(); - let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o)); if only == "jpeg" { - // a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes - for path in &args { - let bytes = std::fs::read(path).expect("read"); - for edge in [256u32, 512, 1600, 2560] { - let o = lightcraft_codecs::DecodeOptions::fit(edge, edge); - let d = lightcraft_codecs::decode(&bytes, o).expect("decode"); - println!( - " {path} fit {edge}: {}×{} {}", - d.image.width, - d.image.height, - best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode"))) - ); - } - } + benchmark_jpeg(&args, n); return; } if only == "load" || only == "load1" { - // opening a photo in the loupe, step by step: what the app's loaders cost (per file) - for path in &args { - let bytes = std::fs::read(path).expect("read"); - println!("{path}"); - if std::env::var("LOADONLY").is_ok() { - // only the preview load (for a profiler) - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - continue; - } - println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read")))); - println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); - let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); - if let lightcraft_raw::RawData::U16(d) = &raw.data { - let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64)); - println!(" decode checksum: {sum:016x}"); - } - if only == "load1" { - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - continue; - } - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { - println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99)))); - } - for edge in [2560usize, 1600] { - println!( - " {:<36} {}", - format!("embedded srgb, whole file ({edge}):"), - best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge))) - ); - let quick = lightcraft_engine::files::fs_preview_loader(); - println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge)))); - } - println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load")))); - let (loader, _) = lightcraft_engine::files::fs_hooks(); - println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load")))); - } + benchmark_load(&args, n, &only); return; } if only == "source" { - // opening a photo: where the time goes before the first sharp loupe (per file) - for path in &args { - let bytes = std::fs::read(path).expect("read"); - println!("{path}"); - let mut raw = lightcraft_raw::decode(&bytes).expect("decode"); - // as the loader does: lens corrections are applied by the pipeline, not in the raw develop - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); - println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); - println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); - let norm = raw.normalized().expect("norm"); - println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd)))); - if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { - println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99)))); - let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable"); - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - println!( - " {:<36} {}", - "highlight reconstruct (binned):", - best(n, || { - let mut i = img.clone(); - lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99); - }) - ); - println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone()))); - println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box)))); - } - println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560)))); - for edge in [512usize, 2560, usize::MAX] { - println!( - " {:<36} {}", - format!("load_bytes({edge}):"), - best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load"))) - ); - } - } + benchmark_source(&args, n); return; } + let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o)); let (full, info) = match args.first() { Some(path) => { let bytes = std::fs::read(path).expect("read"); diff --git a/crates/engine/src/camera_preview/colour.rs b/crates/engine/src/camera_preview/colour.rs index a33a5c7a5..2681466a9 100644 --- a/crates/engine/src/camera_preview/colour.rs +++ b/crates/engine/src/camera_preview/colour.rs @@ -1,7 +1,7 @@ use super::look_fit::{fit_dng_tone, fit_preview}; use super::mono::{fit_dark_tone, monochrome_look}; use super::proxy::{LensGeometry, proxies}; -use super::*; +use super::{CameraLook, CameraTone, CameraTransform, ColorData, D50, D65, Mat3, ProfileLook, REC2020, RawFormat, RawImage, ToneCurve, Xy, bradford}; /// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates /// below were set at this size). diff --git a/crates/engine/src/camera_preview/hue_sat.rs b/crates/engine/src/camera_preview/hue_sat.rs index 7e92ba519..b3692dc4f 100644 --- a/crates/engine/src/camera_preview/hue_sat.rs +++ b/crates/engine/src/camera_preview/hue_sat.rs @@ -1,6 +1,6 @@ use super::mono::to_prophoto; use super::pairs::{displayed, luma}; -use super::*; +use super::{CameraLook, CameraTone, HsvTable, Mat3, ToneMap, luminance_2020}; /// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour /// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps diff --git a/crates/engine/src/camera_preview/look_fit.rs b/crates/engine/src/camera_preview/look_fit.rs index c1588093b..544db5f5f 100644 --- a/crates/engine/src/camera_preview/look_fit.rs +++ b/crates/engine/src/camera_preview/look_fit.rs @@ -3,7 +3,7 @@ use super::mono::{fit_tone_only_with, monochrome}; use super::pairs::{Attempt, TONE_FITS, fit_attempt, fit_partial, search_ordered}; use super::proxy::{CLIP_TOLERANCE, LensGeometry, MIN_PREVIEW_FRACTION, proxies, structure_agrees}; use super::tone::{HighlightCheck, highlights}; -use super::*; +use super::{CameraLook, CameraTone, CameraTransform, HsvTable, Mat3, RawImage, Rgb32f, ToneMap, luminance_2020}; /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option, bool) { diff --git a/crates/engine/src/camera_preview/mono.rs b/crates/engine/src/camera_preview/mono.rs index cfac90c34..3c0bd5736 100644 --- a/crates/engine/src/camera_preview/mono.rs +++ b/crates/engine/src/camera_preview/mono.rs @@ -7,7 +7,7 @@ use super::pairs::{ }; use super::proxy::{LensGeometry, proxies}; use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold}; -use super::*; +use super::{CameraLook, CameraTransform, D50, D65, HsvTable, Mat3, PROPHOTO, REC2020, RawImage, Rgb32f, ToneMap, bradford, luminance_2020}; /// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey. const GREY_SPREAD: f32 = 0.002; diff --git a/crates/engine/src/camera_preview/pairs.rs b/crates/engine/src/camera_preview/pairs.rs index 2fce1a871..425f267da 100644 --- a/crates/engine/src/camera_preview/pairs.rs +++ b/crates/engine/src/camera_preview/pairs.rs @@ -3,7 +3,7 @@ use super::proxy::DARK_FLOOR; #[cfg(test)] use super::tone::highlights; use super::tone::{HighlightCheck, ToneFit, highlights_hold}; -use super::*; +use super::{CameraLook, CameraTone, HsvTable, Mat3, Rgb32f, ToneMap, luminance_2020}; /// A fit must cut the held-out squared error to below this share of the fallback's. pub(super) const MIN_IMPROVEMENT: f64 = 0.7; diff --git a/crates/engine/src/camera_preview/proxy.rs b/crates/engine/src/camera_preview/proxy.rs index 81f170a87..b3db0a160 100644 --- a/crates/engine/src/camera_preview/proxy.rs +++ b/crates/engine/src/camera_preview/proxy.rs @@ -2,7 +2,7 @@ use super::colour::{PROFILE_PROXY, PROXY, file_local_look}; use super::hue_sat::fit_hue_sat; use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped}; use super::pairs::{collect_pairs, fit_matrix}; -use super::*; +use super::{CameraTransform, Filter, HsvTable, Mat3, RawFormat, RawImage, Rgb32f, fit, luminance_2020, resize}; /// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a /// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview diff --git a/crates/engine/src/camera_preview/tone.rs b/crates/engine/src/camera_preview/tone.rs index 19b66948c..f16b8e899 100644 --- a/crates/engine/src/camera_preview/tone.rs +++ b/crates/engine/src/camera_preview/tone.rs @@ -1,6 +1,6 @@ use super::look_fit::dro_off; use super::pairs::{Pairs, displayed, luma}; -use super::*; +use super::{CameraTone, Rgb32f, ToneMap, luminance_2020}; /// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order). #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 6f3f88963..b24246592 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -35,8 +35,9 @@ fn browse(s: &mut Session, p: &Value) -> Result { let mut v: Vec = std::fs::read_dir(&dir) .map_err(|e| bad(C, format!("{path}: {e}")))? .flatten() + .filter(|e| !crate::walk::is_hidden_entry(e)) .map(|e| e.path()) - .filter(|f| f.is_file() && is_supported(f) && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))) + .filter(|f| f.is_file() && is_supported(f)) .map(|f| f.to_string_lossy().to_string()) .collect(); v.sort(); diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index 644d3b89e..1cb99ccf3 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -129,16 +129,18 @@ fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result { /// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing /// a model is how someone says they want it. `verified` is for a download whose hash was already checked. -fn install_file( - dir: &Path, - loader: &crate::denoise::Loader, +struct InstallFile<'a> { + dir: &'a Path, run_on: RunOn, accepted_at: String, - c: &str, - src: &Path, + command: &'a str, + src: &'a Path, acknowledged: bool, verified: bool, -) -> Result<(Value, Arc)> { +} + +fn install_file(loader: &crate::denoise::Loader, request: InstallFile<'_>) -> Result<(Value, Arc)> { + let InstallFile { dir, run_on, accepted_at, command: c, src, acknowledged, verified } = request; let ident = identify(src, c, None)?; let m = ident.manifest.clone(); manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?; @@ -262,7 +264,8 @@ fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, veri .spawn(move || { let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024); - install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e)) + install_file(&loader, InstallFile { dir: &dir, run_on, accepted_at: at, command: C, src: &src, acknowledged: true, verified }) + .map_err(|e| plain(&e)) })) .unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into())); if verified { @@ -290,8 +293,18 @@ fn install(s: &mut Session, p: &Value) -> Result { if s.denoise.install.is_some() { return Err(bad(C, "another model is being installed")); } - let (result, model) = - install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?; + let (result, model) = install_file( + &s.denoise.loader, + InstallFile { + dir: &models_dir(s, C)?, + run_on: s.denoise.settings.run_on(), + accepted_at: (s.clock)(), + command: C, + src: Path::new(path), + acknowledged, + verified: false, + }, + )?; activate_install(s, &result, model, activate) } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 949c08f1b..9af0606d9 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -38,7 +38,7 @@ impl Session { } // the preset's depth is judged against the format written, known only now (`path`); a // depth the call passes itself is the caller's, and validated as such - if !p.get("bitDepth").is_some_and(|d| !d.is_null()) { + if p.get("bitDepth").is_none_or(|d| d.is_null()) { crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback); } Ok(out) diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index feb9b033e..63f8d8f17 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -188,7 +188,10 @@ mod download { use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP}; - use super::*; + use super::{ + Cancel, DownloadOutcome, GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, PoisonError, Result, Session, Value, bad, dataset_dir, json, + str_param, + }; use crate::activity::Unit; const MAX_ZIP: u64 = 64 * 1024 * 1024; diff --git a/crates/engine/src/cmd/library/filters.rs b/crates/engine/src/cmd/library/filters.rs index 1eaa5223f..f0a3a504f 100644 --- a/crates/engine/src/cmd/library/filters.rs +++ b/crates/engine/src/cmd/library/filters.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AlbumId, LibrarySource, PhotoId, Result, Session, Value, bad}; /// `base` with a partial Filter (JSON) merged on top. /// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see diff --git a/crates/engine/src/cmd/library/import.rs b/crates/engine/src/cmd/library/import.rs index 81b82671f..8437bf165 100644 --- a/crates/engine/src/cmd/library/import.rs +++ b/crates/engine/src/cmd/library/import.rs @@ -1,5 +1,5 @@ use super::selection::strs; -use super::*; +use super::{AlbumId, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, str_param}; /// The auto-import folder's visible files with their sizes (the file-system half of /// `library.autoImportScan`; the app lists on a worker thread and passes `listing`). diff --git a/crates/engine/src/cmd/library/selection.rs b/crates/engine/src/cmd/library/selection.rs index 1ed1868d7..723b7c331 100644 --- a/crates/engine/src/cmd/library/selection.rs +++ b/crates/engine/src/cmd/library/selection.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AlbumId, Flag, Op, PhotoId, Result, Selection, Session, Value, bad, bool_or, json, ok}; /// 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 diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs index 07ab6f221..2e7c1311d 100644 --- a/crates/engine/src/cmd/library/specs.rs +++ b/crates/engine/src/cmd/library/specs.rs @@ -5,7 +5,10 @@ use super::selection::{ album_param, album_targets, album_targets_or_err, change_flag, change_rating, cull, flip, for_targets, has_library, mark_deleted, opt_album_param, rotate, row_step, select_after_deletion, select_by, step, strs, }; -use super::*; +use super::{ + Album, AlbumId, ColorLabel, CommandSpec, CopyrightStatus, Filter, Flag, GroupBy, LibrarySource, Op, PhotoId, Result, Selection, Sort, SortKey, + Value, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, json, ok, str_param, +}; /// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. const MAX_SEED: u64 = 1 << 53; diff --git a/crates/engine/src/crs/tests.rs b/crates/engine/src/crs/tests.rs index 2779194c1..9f46cf401 100644 --- a/crates/engine/src/crs/tests.rs +++ b/crates/engine/src/crs/tests.rs @@ -37,9 +37,16 @@ fn props(x: &str) -> Props { #[test] fn maps_common_fields() { let p = props(SIDECAR); - assert!(has_adjustments(&p)); let partial = to_partial(&p, Target::RawAbsolute); let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert!(has_adjustments(&p)); + assert_common_tone_fields(&s); + assert_common_color_fields(&s); + assert_common_detail_fields(&s); + assert_common_geometry_fields(&s); +} + +fn assert_common_tone_fields(s: &DevelopSettings) { assert_eq!(s.wb.mode, WbMode::Custom); assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); assert_eq!(s.light.exposure, 0.65); @@ -54,15 +61,24 @@ fn maps_common_fields() { assert_eq!(s.curve.refine_saturation, 40.0); assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); +} + +fn assert_common_color_fields(s: &DevelopSettings) { assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); +} + +fn assert_common_detail_fields(s: &DevelopSettings) { assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); assert_eq!(s.vignette.amount, -21.0); assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); +} + +fn assert_common_geometry_fields(s: &DevelopSettings) { assert_eq!(s.geometry.upright, Upright::Level); assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); diff --git a/crates/engine/src/denoise/gpu.rs b/crates/engine/src/denoise/gpu.rs index 1577b4edc..a2a493fab 100644 --- a/crates/engine/src/denoise/gpu.rs +++ b/crates/engine/src/denoise/gpu.rs @@ -1,7 +1,7 @@ #[cfg(feature = "denoise")] use super::model::{GpuSide, STACK}; #[cfg(feature = "denoise")] -use super::*; +use super::{Arc, Duration, Mutex, Path, PathBuf, PoisonError, SystemTime, TileRunner}; /// Tiles run at once on a graphics card: enough to keep it fed while others are packed and blended. #[cfg(feature = "denoise")] diff --git a/crates/engine/src/denoise/model.rs b/crates/engine/src/denoise/model.rs index 28ff953bf..c90641574 100644 --- a/crates/engine/src/denoise/model.rs +++ b/crates/engine/src/denoise/model.rs @@ -4,8 +4,9 @@ use super::gpu::{GPU_CRASHED, GPU_MAX_FALLBACKS, GPU_PARALLEL, GPU_SLOW, GPU_STU use super::settings::{installed_models, read_settings}; #[cfg(feature = "denoise")] use super::state::Pace; -use super::*; - +use super::{Arc, DenoiserManifest, Mutex, Path, TileRunner, Value, json}; +#[cfg(feature = "denoise")] +use super::{AtomicBool, AtomicUsize, OnceLock, Ordering, PathBuf, PoisonError, RunError}; /// Stack of the threads that run the model (the runtime recurses through its graph). pub(super) const STACK: usize = 16 << 20; diff --git a/crates/engine/src/denoise/products.rs b/crates/engine/src/denoise/products.rs index 499e8fd6e..bf601b14d 100644 --- a/crates/engine/src/denoise/products.rs +++ b/crates/engine/src/denoise/products.rs @@ -1,6 +1,9 @@ use super::model::{Loader, Model, ModelSlot, RunOn, STACK}; use super::settings::EXT; -use super::*; +use super::{ + Arc, AtomicBool, AtomicUsize, ByteReader, Condvar, Control, DenoiserManifest, HashSet, Hasher128, Layout, Mutex, OnceLock, Ordering, Params, + Path, PathBuf, Photo, PoisonError, RunError, Source, SystemTime, denoise_bayer, product, +}; /// Samples at or above this (white = 1) are clipped: the model leaves them as the camera recorded them. const CLIP: f32 = 0.99; diff --git a/crates/engine/src/denoise/session.rs b/crates/engine/src/denoise/session.rs index 6a452f693..10529d61d 100644 --- a/crates/engine/src/denoise/session.rs +++ b/crates/engine/src/denoise/session.rs @@ -4,7 +4,10 @@ use super::model::STACK; use super::products::{Active, JobSpec, MakeError, Progress, eligible, make_product, product_key, product_path, products_in}; use super::settings::{DIR, SETTINGS_TTL, installed_models, read_settings}; use super::state::{AUTO_LOOK, Failure, MAX_QUEUE, Outcome, Pace, PhotoState, Running}; -use super::*; +use super::{ + Arc, DenoiseSpec, DevelopSettings, HashMap, HashSet, MakeProduct, Mutex, Ordering, PairLoader, Path, PathBuf, Photo, PhotoId, PoisonError, + Selection, Session, Source, Value, json, product, +}; impl Session { /// Where the active model's tiles run (see [`Model::device`]); `{kind: "none"}` while no model is loaded. diff --git a/crates/engine/src/denoise/settings.rs b/crates/engine/src/denoise/settings.rs index f8acd5320..d71c7b074 100644 --- a/crates/engine/src/denoise/settings.rs +++ b/crates/engine/src/denoise/settings.rs @@ -1,6 +1,6 @@ use super::gpu::GPU_SETUP_MARKER; use super::model::RunOn; -use super::*; +use super::{DenoiserManifest, Deserialize, Duration, Path, PathBuf, Serialize, Value}; /// Extension of a product file. pub const EXT: &str = "lcdn"; diff --git a/crates/engine/src/denoise/state.rs b/crates/engine/src/denoise/state.rs index 76a4b6130..37b79cf8b 100644 --- a/crates/engine/src/denoise/state.rs +++ b/crates/engine/src/denoise/state.rs @@ -1,7 +1,7 @@ use super::model::{Loader, default_loader}; use super::products::{Active, Progress}; use super::settings::Settings; -use super::*; +use super::{Arc, Downloads, HashMap, Mutex, PathBuf, PhotoId, Serialize, VecDeque}; /// Most photos waiting in the queue. pub(super) const MAX_QUEUE: usize = 5000; diff --git a/crates/engine/src/export/batch.rs b/crates/engine/src/export/batch.rs index a4bb859e2..b5d45b125 100644 --- a/crates/engine/src/export/batch.rs +++ b/crates/engine/src/export/batch.rs @@ -1,6 +1,6 @@ +use super::ExportOptions; use super::place::Placer; use super::prepare::{Destination, Exported, PreparedExport}; -use super::*; /// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to /// `write`. `exists` tells whether a path is taken. The conflict policy applies to a file and its diff --git a/crates/engine/src/export/encoding.rs b/crates/engine/src/export/encoding.rs index c71903ac9..862c72cd0 100644 --- a/crates/engine/src/export/encoding.rs +++ b/crates/engine/src/export/encoding.rs @@ -1,9 +1,16 @@ use super::options::{ExportFormat, MetadataPolicy, SharpenAmount, SharpenFor}; use super::watermark::{Watermark, draw_watermark, draw_watermark_deep}; -use super::*; +use super::{ + ChromaSubsampling, DateTime, DeepImage, DeepSamples, EncodeImage, EncodeMeta, ExportOptions, Gps, Metadata, NamedSpace, OutputDepth, OutputSpace, + Rgba8, Samples, encode, icc, +}; +use std::borrow::Cow; /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { + if !sharpens(img.width, img.height, target) { + return; + } let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); if let Some(out) = unsharp(img.width, img.height, &src, target, amount) { for (p, v) in img.data.iter_mut().zip(out) { @@ -17,6 +24,9 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount /// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light). pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) { let (w, h) = (img.width, img.height); + if !sharpens(w, h, target) { + return; + } match &mut img.samples { DeepSamples::U16(v) => { let src: Vec<[f32; 3]> = v.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); @@ -37,6 +47,11 @@ pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: Shar } } +/// Whether output sharpening for `target` changes a `w × h` image ([`unsharp`] returns `None` otherwise). +fn sharpens(w: usize, h: usize, target: SharpenFor) -> bool { + target != SharpenFor::None && w >= 3 && h >= 3 +} + /// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done. fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option> { let (radius, base) = match target { @@ -57,8 +72,9 @@ fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: Sha let r = radius as isize; let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { let mut out = vec![[0.0; 3]; inp.len()]; - for y in 0..h { - for x in 0..w { + // rows in parallel: each output pixel is computed on its own, so the bits don't depend on the split + lightcraft_raster::par_rows(&mut out, w, |y, row| { + for (x, o) in row.iter_mut().enumerate() { let mut acc = [0.0f32; 3]; for d in -r..=r { let (sx, sy) = if horizontal { @@ -70,9 +86,9 @@ fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: Sha acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; } let n = (2 * r + 1) as f32; - out[y * w + x] = acc.map(|v| v / n); + *o = acc.map(|v| v / n); } - } + }); out }; let blur = box_pass(&box_pass(src, true), false); @@ -142,8 +158,12 @@ fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_cod 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 { +/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`: the render itself when +/// there is neither (a copy is ~100 MB at 24 MP). +fn finish_8bit<'a>(img: &'a Rgba8, o: &ExportOptions) -> Cow<'a, Rgba8> { + if o.watermark.is_none() && !sharpens(img.width, img.height, o.sharpen) { + return Cow::Borrowed(img); + } let mut img = img.clone(); output_sharpen(&mut img, o.sharpen, o.sharpen_amount); let space = o.effective_space(); @@ -151,18 +171,21 @@ fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; draw_watermark(&mut img, &wm); } - img + Cow::Owned(img) } -/// Output sharpening and the watermark on a high-bit-depth render. -fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { +/// Output sharpening and the watermark on a high-bit-depth render (the render itself when there is neither). +fn finish_deep<'a>(img: &'a DeepImage, o: &ExportOptions) -> Cow<'a, DeepImage> { + if o.watermark.is_none() && !sharpens(img.width, img.height, o.sharpen) { + return Cow::Borrowed(img); + } let mut img = img.clone(); output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); if let Some(wm) = &o.watermark { let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; draw_watermark_deep(&mut img, &wm); } - img + Cow::Owned(img) } /// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) diff --git a/crates/engine/src/export/options.rs b/crates/engine/src/export/options.rs index d1b767be2..5edf078aa 100644 --- a/crates/engine/src/export/options.rs +++ b/crates/engine/src/export/options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/crates/engine/src/export/params.rs b/crates/engine/src/export/params.rs index 3ef9316da..795d8f367 100644 --- a/crates/engine/src/export/params.rs +++ b/crates/engine/src/export/params.rs @@ -1,7 +1,7 @@ use super::options::{Conflict, DestinationMode, ExportFormat, HdrCurve, MetadataPolicy, Resize, ResizeMode, SharpenAmount, SharpenFor}; use super::prepare::DngCompression; use super::watermark::{Anchor, Watermark}; -use super::*; +use super::{ExportOptions, JxlColour, JxlEffort, JxlOptions, OutputDepth, OutputSpace, TiffCompression}; impl Default for ExportOptions { fn default() -> Self { diff --git a/crates/engine/src/export/place.rs b/crates/engine/src/export/place.rs index f0b1ace61..d223a3a79 100644 --- a/crates/engine/src/export/place.rs +++ b/crates/engine/src/export/place.rs @@ -1,6 +1,6 @@ +use super::ExportOptions; use super::options::{Conflict, DestinationMode}; use super::prepare::{Destination, Exported}; -use super::*; /// Write an exported or rendered file on disk: its folder is created if needed, and the file is /// replaced atomically ([`lightcraft_catalog::safe_file::write_atomic_nosync`]: a temp file diff --git a/crates/engine/src/export/prepare.rs b/crates/engine/src/export/prepare.rs index 1fe988832..0a3dce119 100644 --- a/crates/engine/src/export/prepare.rs +++ b/crates/engine/src/export/prepare.rs @@ -2,7 +2,7 @@ use super::batch::run_batch; use super::encoding::{encode_gain_map_jpeg, encode_rendered, export_metadata, removes_location}; use super::options::ExportFormat; use super::params::apple_photos_unsupported; -use super::*; +use super::{ExportOptions, Metadata, OutputDepth}; /// One exported file. pub struct Exported { diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs index 5ad311c7d..139939a1d 100644 --- a/crates/engine/src/export/tests.rs +++ b/crates/engine/src/export/tests.rs @@ -209,6 +209,42 @@ fn sharpen_increases_edge_contrast() { assert!(img.data[3][0] < 80 && img.data[4][0] > 170); } +/// Output sharpening's pixels for every target and amount, pinned (its box blurs run row-parallel, which must not +/// change a bit), on 8- and 16-bit images; with no target the image is left as it is. +#[test] +fn sharpened_pixels_are_pinned() { + let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); + let mut got = Vec::new(); + for target in [SharpenFor::Screen, SharpenFor::Matte, SharpenFor::Glossy] { + for amount in [SharpenAmount::Low, SharpenAmount::High] { + let mut img = test_image(); + output_sharpen(&mut img, target, amount); + got.push(fnv(img.data.as_flattened())); + } + let src = test_image(); + let samples = src.data.iter().flat_map(|p| [p[0], p[1], p[2]].map(|v| u16::from(v) * 257)).collect(); + let mut deep = DeepImage { width: src.width, height: src.height, samples: DeepSamples::U16(samples), space: OutputSpace::Srgb }; + output_sharpen_deep(&mut deep, target, SharpenAmount::Standard); + let DeepSamples::U16(v) = &deep.samples else { unreachable!() }; + got.push(fnv(&v.iter().flat_map(|s| s.to_le_bytes()).collect::>())); + } + assert_eq!(got, SHARPENED, "{got:#x?}"); + let mut img = test_image(); + output_sharpen(&mut img, SharpenFor::None, SharpenAmount::High); + assert_eq!(img, test_image()); +} +const SHARPENED: [u64; 9] = [ + 0x1494_273e_0bdc_a61c, + 0x3fbb_40c4_74f4_b0c3, + 0xa27f_8ff5_82a0_99f9, + 0x6550_6571_a38d_4d9f, + 0x5e28_f4f3_fc8a_9685, + 0xdc2b_6f65_9138_3b1d, + 0x8ce6_f0b8_d8ed_8026, + 0x92d8_12e7_4b2e_81fe, + 0x139f_6d78_b1f2_a910, +]; + /// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject` /// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of /// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`. @@ -1031,6 +1067,11 @@ fn avif_ten_bit() { /// unknown keys (naming the nearest known one) and values of the wrong kind or range. #[test] fn export_params_are_validated() { + assert_invalid_export_params(); + assert_export_options_round_trip(); +} + +fn assert_invalid_export_params() { use serde_json::json; let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); let e = err(json!({"longEdgee": 400})); @@ -1056,6 +1097,11 @@ fn export_params_are_validated() { .to_string() .starts_with("invalid parameters for `export.savePreset`") ); +} + +fn assert_export_options_round_trip() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); // everything the presets, the dialog, Export with Previous and `render --opt` send passes for p in builtin_presets() { ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name)); diff --git a/crates/engine/src/export/watermark.rs b/crates/engine/src/export/watermark.rs index 68633794b..c074808ea 100644 --- a/crates/engine/src/export/watermark.rs +++ b/crates/engine/src/export/watermark.rs @@ -1,5 +1,5 @@ use super::encoding::srgb8_in; -use super::*; +use super::{DeepImage, DeepSamples, Deserialize, OutputSpace, Rgba8, Serialize}; /// Where a watermark sits. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 61dbc2544..c9512f6cb 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Condvar, Mutex, PoisonError}; use std::time::Duration; use lightcraft_catalog::PhotoId; -use lightcraft_faces::align::{Rgb, align_to_template, crop_box}; +use lightcraft_faces::align::{FaceCrop, Rgb, align_to_template, crop_box}; use lightcraft_faces::runtime::Embedder; use lightcraft_faces::yunet::{Detector, Face, Options}; use lightcraft_geom::Rect; @@ -221,13 +221,11 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { Some((_, f)) => align_to_template(&img, &scaled(f), iw, ih), None => crop_box( &img, - rect.x0 as f32 * w as f32, - rect.y0 as f32 * h as f32, - rect.x1 as f32 * w as f32, - rect.y1 as f32 * h as f32, - BOX_PAD, - iw, - ih, + FaceCrop { + bounds: (rect.x0 as f32 * w as f32, rect.y0 as f32 * h as f32, rect.x1 as f32 * w as f32, rect.y1 as f32 * h as f32), + padding: BOX_PAD, + output_size: (iw, ih), + }, ), }; // shown by the detector's box where it found this face: every face then has the same tightness diff --git a/crates/engine/src/files/fs.rs b/crates/engine/src/files/fs.rs index dd1f5273c..7012211af 100644 --- a/crates/engine/src/files/fs.rs +++ b/crates/engine/src/files/fs.rs @@ -1,7 +1,7 @@ use super::load::{load_vec, load_vec_pair, load_vec_reduced}; use super::preview::{fit_box, fs_preview_loader, preview_orientation}; use super::probe::probe_bytes; -use super::*; +use super::{Arc, DenoiseSpec, FileLoader, FileProbe, Filter, PairLoader, ReducedLoader, fit}; /// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter), /// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in. diff --git a/crates/engine/src/files/load.rs b/crates/engine/src/files/load.rs index 52b01d7f7..428a84eda 100644 --- a/crates/engine/src/files/load.rs +++ b/crates/engine/src/files/load.rs @@ -1,7 +1,7 @@ use super::colour::bin_factor; use super::preview::{fit_box, load_embedded_preview}; use super::probe::preview_reason; -use super::*; +use super::{Arc, CameraColourModel, DenoiseSpec, Filter, HIGHLIGHT_CLIP, Orientation, Rgb32f, SourceInfo, fit, xy_to_temp_tint}; /// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. /// diff --git a/crates/engine/src/files/preview.rs b/crates/engine/src/files/preview.rs index 49430e62a..825167f5c 100644 --- a/crates/engine/src/files/preview.rs +++ b/crates/engine/src/files/preview.rs @@ -1,5 +1,5 @@ use super::fs::{quick_image_srgb, raf_preview_extent, read_prefix, read_prefix_at}; -use super::*; +use super::{Arc, Filter, Orientation, PreviewLoader, Rgb32f, SourceInfo, fit}; /// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. pub(super) fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation { diff --git a/crates/engine/src/files/probe.rs b/crates/engine/src/files/probe.rs index aff0b884c..12913bc6f 100644 --- a/crates/engine/src/files/probe.rs +++ b/crates/engine/src/files/probe.rs @@ -1,6 +1,6 @@ use super::load::WbScaleOf; use super::preview::embedded_preview_size; -use super::*; +use super::{MediaKind, Meta, Orientation, ProbeInfo}; pub(super) fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); diff --git a/crates/engine/src/import/commit.rs b/crates/engine/src/import/commit.rs index bed8356fb..7d1a69fdd 100644 --- a/crates/engine/src/import/commit.rs +++ b/crates/engine/src/import/commit.rs @@ -2,7 +2,7 @@ use super::defaults::apply_import_defaults; use super::files::copy_into; use super::job::{Prepared, PreparedItem, ReadyFile}; use super::types::{Duplicate, ImportOptions, ImportReport, Kept, Moved, OnDeleted}; -use super::*; +use super::{HashMap, Op, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; /// A batch's items in file order while its transfers are pending ([`ImportJob::run_plan`]). #[derive(Default)] diff --git a/crates/engine/src/import/defaults.rs b/crates/engine/src/import/defaults.rs index 7ca5cecc4..6fd3feb00 100644 --- a/crates/engine/src/import/defaults.rs +++ b/crates/engine/src/import/defaults.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Photo, Serialize, Session}; /// The develop settings a photo gets on import: raws start from their as-shot white balance with /// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index 2cbfc85e7..099794597 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -5,7 +5,7 @@ use super::job::{ImportJob, Prepared, PreparedItem, ReadyFile}; use super::scan::ScanProgress; #[cfg(test)] use super::types::{ImportMode, ImportOptions}; -use super::*; +use super::{Path, PathBuf, ProbeInfo, Session}; /// File extensions offered by import discovery and file pickers (lower case). /// This includes preview-only raws; the file probe decides whether sensor decoding is available. @@ -253,6 +253,7 @@ pub(super) fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: O #[cfg(test)] mod prepared_tests { use super::*; + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the /// files this build cannot import (see [`accept_list`]). diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs index 47bc0756f..1730518d5 100644 --- a/crates/engine/src/import/job.rs +++ b/crates/engine/src/import/job.rs @@ -1,7 +1,7 @@ use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock}; use super::files::{expand, probe_all}; use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted}; -use super::*; +use super::{HashMap, MediaKind, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; /// Import files/folders. See the module docs. pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result { diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs index 61d8d4690..ef925fa05 100644 --- a/crates/engine/src/import/scan.rs +++ b/crates/engine/src/import/scan.rs @@ -1,6 +1,6 @@ use super::files::{expand, probe_all}; use super::types::ImportCandidate; -use super::*; +use super::{HashMap, Path, PathBuf, PhotoId, ProbeInfo, Session, Source}; /// What a [`scan`] needs from the session, so it can run on another thread (a folder on a network /// share can take minutes to read). diff --git a/crates/engine/src/import/types.rs b/crates/engine/src/import/types.rs index 4a848015b..6ad3e2153 100644 --- a/crates/engine/src/import/types.rs +++ b/crates/engine/src/import/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, MediaKind, Photo, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ImportMode { diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 207d1c148..3066187ce 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -858,6 +858,83 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } +fn stage_collections( + s: &mut crate::Session, + data: &CatalogImport, + index: &mut ImportIndex, + ids: &HashMap, + ops: &mut Vec, + warnings: &mut Vec, +) -> usize { + let mut albums = HashMap::new(); + for collection in &data.collections { + if number(collection, "systemOnly") != 0 { + continue; + } + let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local")); + let id = index + .collections + .get(&collection_key) + .copied() + .map(lightcraft_catalog::AlbumId) + .filter(|id| s.catalog.album(*id).is_some()) + .unwrap_or_else(|| s.catalog.alloc_album_id()); + index.collections.insert(collection_key, id.0); + albums.insert(number(collection, "id_local"), id); + } + let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect(); + let mut grouped_members: HashMap> = HashMap::new(); + for member in &data.members { + grouped_members.entry(number(member, "collection")).or_default().push(member); + } + for members in grouped_members.values_mut() { + members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); + } + let mut ordered: Vec<_> = data.collections.iter().collect(); + ordered.sort_by_key(|r| { + let mut depth = 0; + let mut at = number(r, "parent"); + while at != 0 && depth < 64 { + depth += 1; + at = parents.get(&at).copied().unwrap_or(0); + } + depth + }); + for collection in ordered { + let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue }; + let mut album = Album::new(id, text(collection, "name")); + album.parent = albums.get(&number(collection, "parent")).copied(); + album.folder = text(collection, "creationId").contains("group"); + if text(collection, "creationId").contains("smart") { + warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name)); + } + if !album.folder { + album.photos = grouped_members + .get(&number(collection, "id_local")) + .into_iter() + .flatten() + .filter_map(|r| ids.get(&number(r, "image")).copied()) + .collect(); + } + if s.catalog.album(id).is_some() { + // Reimport preserves the user's album name/hierarchy, merges only new membership. + if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) { + let mut photos = old.photos.clone(); + let mut seen: HashSet<_> = photos.iter().copied().collect(); + for photo in album.photos { + if seen.insert(photo) { + photos.push(photo); + } + } + ops.push(Op::SetAlbumPhotos { id, photos }); + } + } else { + ops.push(Op::AddAlbum { album }); + } + } + albums.len() +} + /// Turn the read catalog into library records in one undo step and return the import report /// (added, missing, failed, warnings and skipped images). pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { @@ -1022,78 +1099,13 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co } by_path.entry(key(&src.path)).or_insert(photo_id); } - let mut albums = HashMap::new(); - for collection in &data.collections { - if number(collection, "systemOnly") != 0 { - continue; - } - let collection_key = format!("{}:{}", key(&data.source), number(collection, "id_local")); - let id = index - .collections - .get(&collection_key) - .copied() - .map(lightcraft_catalog::AlbumId) - .filter(|id| s.catalog.album(*id).is_some()) - .unwrap_or_else(|| s.catalog.alloc_album_id()); - index.collections.insert(collection_key, id.0); - albums.insert(number(collection, "id_local"), id); - } - let parents: HashMap<_, _> = data.collections.iter().map(|r| (number(r, "id_local"), number(r, "parent"))).collect(); - let mut grouped_members: HashMap> = HashMap::new(); - for member in &data.members { - grouped_members.entry(number(member, "collection")).or_default().push(member); - } - for members in grouped_members.values_mut() { - members.sort_by(|a, b| text(a, "positionInCollection").cmp(text(b, "positionInCollection"))); - } - let mut ordered: Vec<_> = data.collections.iter().collect(); - ordered.sort_by_key(|r| { - let mut depth = 0; - let mut at = number(r, "parent"); - while at != 0 && depth < 64 { - depth += 1; - at = parents.get(&at).copied().unwrap_or(0); - } - depth - }); - for collection in ordered { - let Some(id) = albums.get(&number(collection, "id_local")).copied() else { continue }; - let mut album = Album::new(id, text(collection, "name")); - album.parent = albums.get(&number(collection, "parent")).copied(); - album.folder = text(collection, "creationId").contains("group"); - if text(collection, "creationId").contains("smart") { - warnings.push(format!("{}: smart rules preserved in archive; imported current membership", album.name)); - } - if !album.folder { - album.photos = grouped_members - .get(&number(collection, "id_local")) - .into_iter() - .flatten() - .filter_map(|r| ids.get(&number(r, "image")).copied()) - .collect(); - } - if s.catalog.album(id).is_some() { - // Reimport preserves the user's album name/hierarchy, merges only new membership. - if let Some(old) = s.catalog.album(id).filter(|a| !a.folder && !a.is_smart()) { - let mut photos = old.photos.clone(); - let mut seen: HashSet<_> = photos.iter().copied().collect(); - for photo in album.photos { - if seen.insert(photo) { - photos.push(photo); - } - } - ops.push(Op::SetAlbumPhotos { id, photos }); - } - } else { - ops.push(Op::AddAlbum { album }); - } - } + let album_count = stage_collections(s, &data, index, &ids, &mut ops, &mut warnings); if !ops.is_empty() { s.commit("Import Lightroom Catalog", Op::Batch { ops })?; } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":album_count,"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } diff --git a/crates/engine/src/media/cache.rs b/crates/engine/src/media/cache.rs index c3d323e38..238870c07 100644 --- a/crates/engine/src/media/cache.rs +++ b/crates/engine/src/media/cache.rs @@ -1,6 +1,6 @@ use super::settings_hash::SettingsHashes; use super::source::{DecodedSource, DenoiseSpec, PairLoader, RENDERED_MEM_BYTES, SourceLevel, SourceRef, THUMB_SOURCE_BYTES}; -use super::*; +use super::{Arc, DevelopSettings, Hasher128, Lru, MediaCache, Photo, PhotoId, PreviewCache, Rgb32f, Source, SourceInfo}; /// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the /// file system, so it can be asked on the UI thread. diff --git a/crates/engine/src/media/quick.rs b/crates/engine/src/media/quick.rs index 6098ed4dd..ca399b561 100644 --- a/crates/engine/src/media/quick.rs +++ b/crates/engine/src/media/quick.rs @@ -1,6 +1,6 @@ use super::render::{RenderJob, RenderResult}; use super::source::{PreviewLoader, SourceLevel}; -use super::*; +use super::{Arc, Deserialize, Hash128, Histogram, Photo, PhotoId, PreviewCache, Rendered, Rgba8, Serialize, Source, SourceInfo}; /// Where a [`QuickJob`]'s stand-in image came from. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/media/render.rs b/crates/engine/src/media/render.rs index 1192a98e0..a87a0792c 100644 --- a/crates/engine/src/media/render.rs +++ b/crates/engine/src/media/render.rs @@ -1,6 +1,8 @@ use super::quick::QuickSource; use super::source::{DecodedSource, SourceLevel, SourceRef}; -use super::*; +use super::{ + Arc, DevelopSettings, Hash128, Histogram, PhotoId, PreviewCache, Quality, RenderRequest, Rendered, Rgb32f, Rgba8, Source, SourceInfo, StageCache, +}; /// Everything needed to render one photo, detached from the session. #[derive(Clone)] diff --git a/crates/engine/src/media/session.rs b/crates/engine/src/media/session.rs index d160b216d..b222739fe 100644 --- a/crates/engine/src/media/session.rs +++ b/crates/engine/src/media/session.rs @@ -1,7 +1,7 @@ use super::quick::{QuickJob, content_key, source_info}; use super::render::{RenderJob, RenderResult}; use super::source::{PreviewLoader, RENDER_CACHE_VERSION, SourceLevel, SourceRef, THUMB_SIZES, face_edge, source_edge_needed, thumb_bucket}; -use super::*; +use super::{Arc, DevelopSettings, Hash128, Hasher128, MediaKind, Op, Photo, PhotoId, RenderRequest, Rendered, Rgb32f, Source, SourceInfo}; impl crate::Session { /// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look. diff --git a/crates/engine/src/media/settings_hash.rs b/crates/engine/src/media/settings_hash.rs index 5d9c08b93..802918fb9 100644 --- a/crates/engine/src/media/settings_hash.rs +++ b/crates/engine/src/media/settings_hash.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DevelopSettings, HashMap, PhotoId, VecDeque, Weak}; pub(super) const SETTINGS_HASH_ENTRIES: usize = 1024; diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs index 51c54c60a..8490d2ee2 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,7 +1,7 @@ -use super::*; +use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 70; +pub const RENDER_CACHE_VERSION: u64 = 71; /// 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/originals.rs b/crates/engine/src/originals.rs index 890e98f81..af2fc5d14 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; #[cfg(not(any(unix, windows)))] use std::time::SystemTime; -use lightcraft_catalog::safe_file::same_file; +use lightcraft_catalog::safe_file::{same_file, same_file_strict}; use lightcraft_catalog::{Catalog, Source}; /// The directory entry a screenshot is about to write, pinned at request admission. @@ -147,14 +147,30 @@ impl OriginalGuard { if let Some(n) = std::fs::canonicalize(target).ok().and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_lowercase())) { names.push(n); } + // The identity check refuses a target whose file identity cannot be read, so an unreadable + // hard link to an original is not taken for an ordinary file. + let refuse_if_same = |p: &Path, what: &str| -> Result<(), String> { + let same = if hard_links { + same_file_strict(p, target).map_err(|e| { + format!( + "{}: LightCraft cannot tell whether this is {what} in the library ({e}) and never writes over an original (choose another folder or file name)", + target.display() + ) + })? + } else { + same_file(p, target) + }; + if same { + return Err(format!( + "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + target.display() + )); + } + Ok(()) + }; for n in &names { for (p, what) in self.by_name.get(n).into_iter().flatten() { - if same_file(p, target) { - return Err(format!( - "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", - target.display() - )); - } + refuse_if_same(p, what)?; } } // An imported hard link or symlink can have a different name from its original. A write @@ -166,12 +182,7 @@ impl OriginalGuard { if !hard_links && std::fs::read_link(p).is_err() { continue; } - if same_file(p, target) { - return Err(format!( - "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", - target.display() - )); - } + refuse_if_same(p, what)?; } Ok(()) } @@ -339,4 +350,32 @@ mod tests { } let _ = std::fs::remove_dir_all(&d); } + + #[cfg(windows)] + #[test] + fn a_hard_link_whose_identity_cannot_be_read_is_refused() { + use std::os::windows::fs::OpenOptionsExt; + let d = std::env::temp_dir().join(format!("lc-unreadable-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let mut s = crate::Session::new(); + let original = d.join("IMG_1.jpg"); + std::fs::write(&original, b"original").unwrap(); + let photo = Photo::new(PhotoId(1), Source::File { path: original.to_string_lossy().to_string() }, "IMG_1.jpg", "JPEG", 10, 10, "2026-01-01"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let linked = d.join("shot.png"); + std::fs::hard_link(&original, &linked).unwrap(); + + // A handle that shares nothing makes every other open of the file fail, so the file + // identity of either name cannot be read while it is held. + let held = std::fs::OpenOptions::new().read(true).share_mode(0).open(&original).unwrap(); + let refused = s.pin_write_target(&linked.to_string_lossy()).unwrap_err(); + assert!(refused.contains("cannot tell whether"), "{refused}"); + drop(held); + + let refused = s.pin_write_target(&linked.to_string_lossy()).unwrap_err(); + assert!(refused.contains("original"), "{refused}"); + assert!(!refused.contains("cannot tell whether"), "{refused}"); + let _ = std::fs::remove_dir_all(&d); + } } diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index f1d787ae0..d1dbc946c 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -502,12 +502,16 @@ pub fn group_from_dir(dir: &str) -> Option { } pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option, from_photo: bool) -> Option { - let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT); + let (mut settings, mut unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT); if from_photo && let Some(o) = settings.as_object_mut() { // a photo's own framing and absolute white balance don't belong in a look o.remove("crop"); o.remove("geometry"); - if o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str).is_none_or(|m| m == "custom" || m == "asShot") { + let mode = o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str); + if mode == Some("custom") { + unmapped.push("Temperature, Tint (custom white balance of a photo: not kept in a look)".into()); + } + if mode.is_none_or(|m| m == "custom" || m == "asShot") { o.remove("wb"); } } @@ -750,6 +754,23 @@ text]], z = ZSTR "loc" }"#, assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err()); } + #[test] + fn photo_custom_white_balance_left_out_is_reported() { + // issue #2938: a photo's custom Kelvin that the look leaves out is reported + let photo = |wb: &str| { + let mut file = b"\xff\xd8\xff\xe1 http://ns.adobe.com/xap/1.0/\0".to_vec(); + file.extend_from_slice(XMP.replace(r#"crs:WhiteBalance="As Shot" crs:Temperature="5600""#, wb).as_bytes()); + read_presets("/x/repro.jpg", &file, None).unwrap().remove(0) + }; + let custom = photo(r#"crs:WhiteBalance="Custom" crs:Temperature="8000" crs:Tint="+16""#); + assert!(custom.unmapped.iter().any(|u| u.starts_with("Temperature, Tint (")), "{:?}", custom.unmapped); + // a named white balance stays, unreported; As Shot is no loss + let daylight = photo(r#"crs:WhiteBalance="Daylight""#); + assert_eq!(daylight.preset.settings["wb"]["mode"], json!("daylight")); + assert!(daylight.unmapped.is_empty(), "{:?}", daylight.unmapped); + assert!(photo(r#"crs:WhiteBalance="As Shot""#).unmapped.is_empty()); + } + #[test] fn zip_bombs_are_refused() { // an entry unpacking past the limit (3 MB of zeros pack into a few kB): refused, named diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index d8e1eeb87..16c683a84 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -255,6 +255,15 @@ pub struct Image16 { pub rgb: Vec, } +/// A rectangular image region used as a source or destination for `Image16::blit`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BlitRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + impl Image16 { pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result { let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?; @@ -284,9 +293,11 @@ impl Image16 { } /// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images. - pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) { - let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); - let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); + pub fn blit(&mut self, src: &Image16, source: BlitRect, destination: BlitRect) { + let BlitRect { x: sx, y: sy, .. } = source; + let BlitRect { x: dx, y: dy, .. } = destination; + let w = source.w.min(destination.w).min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); + let h = source.h.min(destination.h).min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); for row in 0..h { let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3); let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3); @@ -424,7 +435,7 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell if border > 0 { page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border); } - page.blit(&img, sx, sy, dx, dy, w, h); + page.blit(&img, BlitRect { x: sx, y: sy, w, h }, BlitRect { x: dx, y: dy, w, h }); let s = style.stroke_px.min(w.min(h) / 2); if s > 0 { page.fill_rect(dx, dy, w, s, style.stroke); diff --git a/crates/engine/src/print/compose/tests.rs b/crates/engine/src/print/compose/tests.rs index cdec92799..f1e979cc0 100644 --- a/crates/engine/src/print/compose/tests.rs +++ b/crates/engine/src/print/compose/tests.rs @@ -182,9 +182,9 @@ fn rotate_and_blit_are_clipped() { let r = img.rotated_cw().unwrap(); assert_eq!((r.w, r.h), (2, 3)); let mut p = page(); - p.blit(&img, 0, 0, 99, 99, 10, 10); + p.blit(&img, BlitRect { x: 0, y: 0, w: 10, h: 10 }, BlitRect { x: 99, y: 99, w: 10, h: 10 }); assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); - p.blit(&img, 5, 5, 0, 0, 3, 3); + p.blit(&img, BlitRect { x: 5, y: 5, w: 3, h: 3 }, BlitRect { x: 0, y: 0, w: 3, h: 3 }); p.fill_rect(95, 95, 50, 50, RED); assert_eq!(at(&p, 99, 99), RED); p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); diff --git a/crates/engine/src/rename/fs.rs b/crates/engine/src/rename/fs.rs index 352140b38..71de1a784 100644 --- a/crates/engine/src/rename/fs.rs +++ b/crates/engine/src/rename/fs.rs @@ -1,5 +1,5 @@ use super::sidecar::PacketVersion; -use super::*; +use super::{Path, PathBuf, SidecarNaming}; /// Every sidecar that may belong to `path` (both naming conventions, `.xmp` and `.XMP`). pub(super) fn sidecars(path: &str) -> Vec<(PathBuf, SidecarNaming, bool)> { diff --git a/crates/engine/src/rename/moves.rs b/crates/engine/src/rename/moves.rs index 6f7c245c9..07d7fdfc3 100644 --- a/crates/engine/src/rename/moves.rs +++ b/crates/engine/src/rename/moves.rs @@ -1,6 +1,6 @@ use super::fs::{MoveFs, RealFs, folded_path, is_respelling, move_one, sidecars}; use super::sidecar::{RetainedPacket, SidecarMove, advance_packets, note_companions, packet_aliases}; -use super::*; +use super::{HashMap, HashSet, Op, Path, PathBuf, SidecarNaming, Source}; /// Why [`move_file`] failed, and what it left behind. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/rename/plan.rs b/crates/engine/src/rename/plan.rs index 331297ba4..4c9620bf1 100644 --- a/crates/engine/src/rename/plan.rs +++ b/crates/engine/src/rename/plan.rs @@ -2,7 +2,7 @@ use super::fs::{MoveFs, RealFs, folded_path}; use super::moves::{BatchMoveError, move_all, set_file_ops_to}; use super::sidecar::{PacketVersion, retry_sidecars, retry_sidecars_for_paths}; use super::tokens::{expand, split_ext}; -use super::*; +use super::{EngineError, HashMap, HashSet, Op, Path, PathBuf, Photo, PhotoId, RenamePlan, Result, Session, Source}; /// The photos among `ids` a rename handles, in order: photos sharing one file (virtual copies) /// rename once. No file-system calls. diff --git a/crates/engine/src/rename/sidecar.rs b/crates/engine/src/rename/sidecar.rs index 24ebcd3f9..85f9302d2 100644 --- a/crates/engine/src/rename/sidecar.rs +++ b/crates/engine/src/rename/sidecar.rs @@ -1,5 +1,5 @@ use super::fs::{MoveFs, is_respelling, move_one}; -use super::*; +use super::{Path, PathBuf}; /// An unfinished sidecar operation. Keep its exact paths and any recorded copy requirement: the photo may /// already have followed its new path, so a retry cannot infer this work from the catalog. diff --git a/crates/engine/src/rename/tokens.rs b/crates/engine/src/rename/tokens.rs index 3490e272b..ee15836e8 100644 --- a/crates/engine/src/rename/tokens.rs +++ b/crates/engine/src/rename/tokens.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{MONTHS, Path, Photo, PhotoId, Serialize, Source}; pub(super) fn split_ext(name: &str) -> (&str, &str) { match name.rsplit_once('.') { diff --git a/crates/engine/src/sidecar/tests_repair_companions.rs b/crates/engine/src/sidecar/tests_repair_companions.rs index 94c7996a7..05402f975 100644 --- a/crates/engine/src/sidecar/tests_repair_companions.rs +++ b/crates/engine/src/sidecar/tests_repair_companions.rs @@ -73,85 +73,85 @@ fn partial_move_companions_allow_save_and_autowrite_without_weakening_collisions for upper_full in [false, true] { for naming in [SidecarNaming::Stem, SidecarNaming::Full] { for auto in [false, true] { - let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); - let a = dir.join("a.jpg"); - let b = dir.join("Trip.jpg"); - std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); - let path = |photo: &Path, style, upper| { - let path = sidecar_path(&photo.to_string_lossy(), style); - if upper { path.with_extension("XMP") } else { path } - }; - let stem_a = path(&a, SidecarNaming::Stem, upper_stem); - let full_a = path(&a, SidecarNaming::Full, upper_full); - let stem_b = path(&b, SidecarNaming::Stem, upper_stem); - let full_b = path(&b, SidecarNaming::Full, upper_full); - let stem_bytes = packet("stem-keep"); - let full_bytes = packet("full-keep"); - std::fs::write(&stem_a, &stem_bytes).unwrap(); - std::fs::write(&full_a, &full_bytes).unwrap(); - let mut s = session(&a); - s.xmp.naming = naming; - s.xmp.auto_write = auto; - let fs = Faults(vec![full_a.clone(), b.clone()]); - let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); - assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); - let pending = queues(&s); - assert_eq!(pending.len(), 1); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - - // Missing packet and an unrelated packet at either case spelling remain protected. - let held = dir.join("held.xmp"); - std::fs::rename(&full_a, &held).unwrap(); - s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); - assert_eq!(queues(&s), pending); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - std::fs::rename(&held, &full_a).unwrap(); - for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { - std::fs::write(&collision, packet("unrelated-packet")).unwrap(); - let occupied = std::fs::read(&collision).unwrap(); - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); - assert_eq!(queues(&s), pending); - assert_eq!(std::fs::read(&collision).unwrap(), occupied); - assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); - assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); - std::fs::remove_file(collision).unwrap(); - } - // AutoWrite is the first successful repair when enabled; explicit save otherwise. - s.execute("photo.rate", &json!({"rating": 4})).unwrap(); - if !auto { - assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); - } - assert!(queues(&s).is_empty()); - assert!(!full_a.exists()); - let (written, untouched, expected) = - if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; - assert_fresh(written); - assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); - assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); - for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { - s.execute(command, &json!({})).unwrap(); - if !auto { - save(&mut s); - } - assert_fresh(written); - assert_eq!( - parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, - Some(rating) - ); - assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); - } - assert_eq!(s.xmp.naming, naming); - assert_eq!(s.xmp.auto_write, auto); - drop(s); - std::fs::remove_dir_all(dir).unwrap(); + check_companion_scenario(upper_stem, upper_full, naming, auto); } } } } } +fn check_companion_scenario(upper_stem: bool, upper_full: bool, naming: SidecarNaming, auto: bool) { + let dir = crate::tests_xmp::temp_dir(&format!("r3-companions-{upper_stem}-{upper_full}-{naming:?}-{auto}")); + let a = dir.join("a.jpg"); + let b = dir.join("Trip.jpg"); + std::fs::write(&a, b"physical fixture; Session rename does not decode").unwrap(); + let path = |photo: &Path, style, upper| { + let path = sidecar_path(&photo.to_string_lossy(), style); + if upper { path.with_extension("XMP") } else { path } + }; + let stem_a = path(&a, SidecarNaming::Stem, upper_stem); + let full_a = path(&a, SidecarNaming::Full, upper_full); + let stem_b = path(&b, SidecarNaming::Stem, upper_stem); + let full_b = path(&b, SidecarNaming::Full, upper_full); + let stem_bytes = packet("stem-keep"); + let full_bytes = packet("full-keep"); + std::fs::write(&stem_a, &stem_bytes).unwrap(); + std::fs::write(&full_a, &full_bytes).unwrap(); + let mut s = session(&a); + s.xmp.naming = naming; + s.xmp.auto_write = auto; + let fs = Faults(vec![full_a.clone(), b.clone()]); + let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queues(&s); + assert_eq!(pending.len(), 1); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + + // Missing packet and an unrelated packet at either case spelling remain protected. + let held = dir.join("held.xmp"); + std::fs::rename(&full_a, &held).unwrap(); + s.execute("photo.setMeta", &json!({"title": "Fresh", "addKeywords": ["café"]})).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::rename(&held, &full_a).unwrap(); + for collision in [full_b.clone(), full_b.with_extension(if upper_full { "xmp" } else { "XMP" })] { + std::fs::write(&collision, packet("unrelated-packet")).unwrap(); + let occupied = std::fs::read(&collision).unwrap(); + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 1); + assert_eq!(queues(&s), pending); + assert_eq!(std::fs::read(&collision).unwrap(), occupied); + assert_eq!(std::fs::read_to_string(&full_a).unwrap(), full_bytes); + assert_eq!(std::fs::read_to_string(&stem_b).unwrap(), stem_bytes); + std::fs::remove_file(collision).unwrap(); + } + // AutoWrite is the first successful repair when enabled; explicit save otherwise. + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + if !auto { + assert_eq!(save(&mut s)["failed"].as_array().unwrap().len(), 0); + } + assert!(queues(&s).is_empty()); + assert!(!full_a.exists()); + let (written, untouched, expected) = + if naming == SidecarNaming::Stem { (&stem_b, &full_b, &full_bytes) } else { (&full_b, &stem_b, &stem_bytes) }; + assert_fresh(written); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(4)); + for (command, rating) in [("edit.undo", 0), ("edit.redo", 4)] { + s.execute(command, &json!({})).unwrap(); + if !auto { + save(&mut s); + } + assert_fresh(written); + assert_eq!(parse_sidecar(&std::fs::read_to_string(written).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(rating)); + assert_eq!(std::fs::read_to_string(untouched).unwrap(), *expected); + } + assert_eq!(s.xmp.naming, naming); + assert_eq!(s.xmp.auto_write, auto); + drop(s); + std::fs::remove_dir_all(dir).unwrap(); +} #[test] fn rollback_repair_and_shared_copy_keep_known_companions() { for rollback in [false, true] { diff --git a/crates/engine/src/sidecar/tests_repair_provenance.rs b/crates/engine/src/sidecar/tests_repair_provenance.rs index 86f7d1dd6..9c9b7c084 100644 --- a/crates/engine/src/sidecar/tests_repair_provenance.rs +++ b/crates/engine/src/sidecar/tests_repair_provenance.rs @@ -348,64 +348,12 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() { // A private-method contract, not a UI reproduction: application reachability of two // simultaneous repair owners is unproven. Native move failures and follow_stuck seed // both records; the second owner's normal apply_rename preflight is deliberately bypassed. - struct NativePartial { - id: PhotoId, - original: PathBuf, - moved: PathBuf, - stem: PathBuf, - full: PathBuf, - companion: PathBuf, - target: PathBuf, - } let root = crate::tests_xmp::temp_dir("detached-history-preflight-contract"); let image = lightcraft_raster::Rgba8::from_fn(4, 3, |_, _| [40, 80, 120, 255]); let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap(); let mut s = Session::new(); s.xmp.naming = SidecarNaming::Stem; - let mut native = Vec::new(); - for (i, name) in ["first", "second"].into_iter().enumerate() { - let dir = root.join(name); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("a.png"); - let moved = dir.join("Trip.png"); - std::fs::write(&original, &png).unwrap(); - let stem = path(&original, SidecarNaming::Stem, false); - let full = path(&original, SidecarNaming::Full, true); - for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { - std::fs::write(p, packet(&marker)).unwrap(); - assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); - } - let id = PhotoId(i as u64 + 1); - let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); - s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); - let fs = Faults(vec![full.clone(), moved.clone()]); - if i == 0 { - let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); - assert!(s.apply_rename_with(&fs, &plans).is_err()); - } else { - // Register the genuine second partial move without draining the first repair. - let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; - let err = crate::rename::move_all(&fs, &moves).unwrap_err(); - assert_eq!(err.stuck, moves); - assert_eq!(err.pending_sidecars.len(), 1); - let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; - s.follow_stuck(&op, err, true); - } - assert_eq!(s.undo.len(), i + 1); - assert_eq!(queued(&s).len(), i + 1); - assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); - let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); - assert_eq!((decoded.width, decoded.height), (4, 3)); - native.push(NativePartial { - id, - original, - companion: path(&moved, SidecarNaming::Stem, false), - target: path(&moved, SidecarNaming::Full, true), - moved, - stem, - full, - }); - } + let native = create_native_partials(&root, &png, &mut s); let first = &native[0]; let second = &native[1]; let first_stem = std::fs::read(&first.companion).unwrap(); @@ -463,3 +411,61 @@ fn detached_history_entry_is_checked_before_unrelated_global_retirement() { drop(s); std::fs::remove_dir_all(root).unwrap(); } + +struct NativePartial { + id: PhotoId, + original: PathBuf, + moved: PathBuf, + stem: PathBuf, + full: PathBuf, + companion: PathBuf, + target: PathBuf, +} + +fn create_native_partials(root: &Path, png: &[u8], s: &mut Session) -> Vec { + let mut native = Vec::new(); + for (i, name) in ["first", "second"].into_iter().enumerate() { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("a.png"); + let moved = dir.join("Trip.png"); + std::fs::write(&original, png).unwrap(); + let stem = path(&original, SidecarNaming::Stem, false); + let full = path(&original, SidecarNaming::Full, true); + for (p, marker) in [(&stem, format!("{name}-stem")), (&full, format!("{name}-full"))] { + std::fs::write(p, packet(&marker)).unwrap(); + assert_eq!(parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().rating, Some(5)); + } + let id = PhotoId(i as u64 + 1); + let photo = Photo::new(id, Source::File { path: original.to_string_lossy().into_owned() }, "a.png", "PNG", 4, 3, ""); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + let fs = Faults(vec![full.clone(), moved.clone()]); + if i == 0 { + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.apply_rename_with(&fs, &plans).is_err()); + } else { + // Register the genuine second partial move without draining the first repair. + let moves = vec![(original.to_string_lossy().into_owned(), moved.to_string_lossy().into_owned())]; + let err = crate::rename::move_all(&fs, &moves).unwrap_err(); + assert_eq!(err.stuck, moves); + assert_eq!(err.pending_sidecars.len(), 1); + let op = Op::SetFile { id, source: Source::File { path: moved.to_string_lossy().into_owned() }, file_name: "Trip.png".into() }; + s.follow_stuck(&op, err, true); + } + assert_eq!(s.undo.len(), i + 1); + assert_eq!(queued(s).len(), i + 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: moved.to_string_lossy().into_owned() }); + let decoded = lightcraft_codecs::decode(&std::fs::read(&moved).unwrap(), Default::default()).unwrap(); + assert_eq!((decoded.width, decoded.height), (4, 3)); + native.push(NativePartial { + id, + original, + companion: path(&moved, SidecarNaming::Stem, false), + target: path(&moved, SidecarNaming::Full, true), + moved, + stem, + full, + }); + } + native +} diff --git a/crates/engine/src/sidecar/tests_repair_saves.rs b/crates/engine/src/sidecar/tests_repair_saves.rs index 4b622e657..6fd50aea6 100644 --- a/crates/engine/src/sidecar/tests_repair_saves.rs +++ b/crates/engine/src/sidecar/tests_repair_saves.rs @@ -58,118 +58,122 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw for save_naming in [SidecarNaming::Stem, SidecarNaming::Full] { for upper in [false, true] { for switch in [false, true] { - let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); - let a = root.join("a.png"); - let b = root.join("Trip.png"); - png(&a); - let mut old = sidecar_path(&a.to_string_lossy(), naming); - if upper { - old = old.with_extension("XMP"); - } - std::fs::write(&old, FOREIGN).unwrap(); - let mut s = Session::new().with_fs(); - s.open_library(root.join("library-a"), false).unwrap(); - s.execute("library.import", &json!({"paths": [a]})).unwrap(); - let mut id = s.catalog.photos().next().unwrap().id; - s.selection = crate::Selection::single(id); - s.undo.clear(); - let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; - let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); - assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(queued(&s).len(), 1); - assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); - let pending = queued(&s); - let held = root.join("temporarily-unavailable.xmp"); - std::fs::rename(&old, &held).unwrap(); - if switch { - s.close_library().unwrap(); - s.open_library(root.join("library-b"), false).unwrap(); - assert_eq!(s.pending_sidecar_repairs, pending); - s.execute("library.import", &json!({"paths": [b]})).unwrap(); - id = s.catalog.photos().next().unwrap().id; - s.selection = crate::Selection::single(id); - } - s.xmp.naming = save_naming; - s.xmp.auto_write = true; - s.execute("photo.rate", &json!({"rating": 4})).unwrap(); - let preferred = sidecar_path(&b.to_string_lossy(), save_naming); - assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); - assert!(!pending[0].to.exists()); - assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); - let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); - assert!(saved["written"].as_array().unwrap().is_empty()); - assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); - assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); - assert_eq!(queued(&s), pending); - - // A missing sidecar for another photo does not prevent this healthy save. - let healthy = root.join("healthy.png"); - png(&healthy); - let healthy_id = PhotoId(999); - s.commit( - "Healthy Photo", - Op::AddPhoto { - photo: Box::new(Photo::new( - healthy_id, - Source::File { path: healthy.to_string_lossy().into_owned() }, - "healthy.png", - "PNG", - 4, - 3, - "", - )), - }, - ) - .unwrap(); - s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); - let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); - assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); - assert_eq!(queued(&s), pending); - - // An existing destination is never overwritten and the original packet remains. - std::fs::rename(&held, &old).unwrap(); - let destination = pending[0].to.clone(); - let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); - let collision_path = if upper { preferred.clone() } else { destination.clone() }; - std::fs::write(&collision_path, &collision).unwrap(); - s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); - assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); - assert_eq!(std::fs::read(&collision_path).unwrap(), collision); - assert_eq!(queued(&s), pending); - s.selection = crate::Selection::single(id); - assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); - assert_eq!(std::fs::read(&collision_path).unwrap(), collision); - std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision - - // The next successful AutoWrite runs the real repair and production XMP merge. - s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); - assert!(queued(&s).is_empty()); - assert!(!old.exists()); - let packet = std::fs::read_to_string(&destination).unwrap(); - assert!(packet.contains("fixture:Marker=\"keep-me\"")); - let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); - assert_eq!(parsed.rating, Some(4)); - assert_eq!(parsed.title.as_deref(), Some("Final title")); - assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); - if upper { - // by listing: on a case-insensitive volume `preferred` answers for the upper-case packet - let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); - assert!(!listed, "merge uses the repaired uppercase packet"); - } - for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { - s.execute(step, &json!({})).unwrap(); - let packet = std::fs::read_to_string(&destination).unwrap(); - assert!(packet.contains("fixture:Marker=\"keep-me\"")); - assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); - } - drop(s); - std::fs::remove_dir_all(root).unwrap(); + check_autowrite_repair_scenario(naming, save_naming, upper, switch); } } } } } +fn check_autowrite_repair_scenario(naming: SidecarNaming, save_naming: SidecarNaming, upper: bool, switch: bool) { + let root = crate::tests_xmp::temp_dir(&format!("repair-save-{naming:?}-{save_naming:?}-{upper}-{switch}")); + let a = root.join("a.png"); + let b = root.join("Trip.png"); + png(&a); + let mut old = sidecar_path(&a.to_string_lossy(), naming); + if upper { + old = old.with_extension("XMP"); + } + std::fs::write(&old, FOREIGN).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(root.join("library-a"), false).unwrap(); + s.execute("library.import", &json!({"paths": [a]})).unwrap(); + let mut id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + s.undo.clear(); + let fs = PartialRename { sidecar: old.clone(), photo: b.clone() }; + let plans = s.plan_rename_with(&fs, &[id], "Trip", 1); + assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); + assert_eq!(queued(&s).len(), 1); + assert_eq!(s.catalog.photo(id).unwrap().source, Source::File { path: b.to_string_lossy().into_owned() }); + let pending = queued(&s); + let held = root.join("temporarily-unavailable.xmp"); + std::fs::rename(&old, &held).unwrap(); + if switch { + s.close_library().unwrap(); + s.open_library(root.join("library-b"), false).unwrap(); + assert_eq!(s.pending_sidecar_repairs, pending); + s.execute("library.import", &json!({"paths": [b]})).unwrap(); + id = s.catalog.photos().next().unwrap().id; + s.selection = crate::Selection::single(id); + } + s.xmp.naming = save_naming; + s.xmp.auto_write = true; + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + let preferred = sidecar_path(&b.to_string_lossy(), save_naming); + assert!(!preferred.exists() && !preferred.with_extension("XMP").exists()); + assert!(!pending[0].to.exists()); + assert_eq!(queued(&s), pending, "unavailable repair stays queued after AutoWrite"); + let saved = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); + assert!(saved["written"].as_array().unwrap().is_empty()); + assert!(saved["failed"][0]["error"].as_str().unwrap().contains("unfinished XMP repair")); + assert_eq!(std::fs::read(&held).unwrap(), FOREIGN); + assert_eq!(queued(&s), pending); + + // A missing sidecar for another photo does not prevent this healthy save. + let healthy = root.join("healthy.png"); + png(&healthy); + let healthy_id = PhotoId(999); + s.commit( + "Healthy Photo", + Op::AddPhoto { + photo: Box::new(Photo::new(healthy_id, Source::File { path: healthy.to_string_lossy().into_owned() }, "healthy.png", "PNG", 4, 3, "")), + }, + ) + .unwrap(); + s.execute("photo.setMeta", &json!({"ids": [healthy_id.0], "title": "Healthy save"})).unwrap(); + let packet = std::fs::read_to_string(sidecar_path(&healthy.to_string_lossy(), save_naming)).unwrap(); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some("Healthy save")); + assert_eq!(queued(&s), pending); + + // An existing destination is never overwritten and the original packet remains. + std::fs::rename(&held, &old).unwrap(); + let destination = pending[0].to.clone(); + let collision = String::from_utf8(FOREIGN.to_vec()).unwrap().replace("Rating=\"2\"", "Rating=\"5\"").into_bytes(); + let collision_path = if upper { preferred.clone() } else { destination.clone() }; + std::fs::write(&collision_path, &collision).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "title": "Fresh title", "addKeywords": ["café"]})).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), FOREIGN); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + assert_eq!(queued(&s), pending); + s.selection = crate::Selection::single(id); + assert_eq!(s.execute("photo.saveMetadataToFile", &json!({})).unwrap()["failed"].as_array().unwrap().len(), 1); + assert_eq!(std::fs::read(&collision_path).unwrap(), collision); + std::fs::remove_file(&collision_path).unwrap(); // remove only this test's collision + + // The next successful AutoWrite runs the real repair and production XMP merge. + s.execute("photo.setMeta", &json!({"title": "Final title"})).unwrap(); + assert!(queued(&s).is_empty()); + assert!(!old.exists()); + let packet = std::fs::read_to_string(&destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + let parsed = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap(); + assert_eq!(parsed.rating, Some(4)); + assert_eq!(parsed.title.as_deref(), Some("Final title")); + assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); + assert_repaired_case_spelling(&root, &preferred, upper); + assert_undo_redo_preserve_foreign_packet(&mut s, &destination); + drop(s); + std::fs::remove_dir_all(root).unwrap(); +} + +fn assert_repaired_case_spelling(root: &Path, preferred: &Path, upper: bool) { + if !upper { + return; + } + // By listing, a case-insensitive volume answers for the uppercase packet. + let listed = std::fs::read_dir(root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); + assert!(!listed, "merge uses the repaired uppercase packet"); +} + +fn assert_undo_redo_preserve_foreign_packet(s: &mut Session, destination: &Path) { + for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { + s.execute(step, &json!({})).unwrap(); + let packet = std::fs::read_to_string(destination).unwrap(); + assert!(packet.contains("fixture:Marker=\"keep-me\"")); + assert_eq!(parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().title.as_deref(), Some(title)); + } +} #[test] fn unowned_shared_stem_repair_does_not_block_healthy_jpeg_full_sidecar_save() { let root = crate::tests_xmp::temp_dir("repair-save-shared-stem"); diff --git a/crates/engine/src/tests/import.rs b/crates/engine/src/tests/import.rs index 6e5585a82..dc1c6c8f2 100644 --- a/crates/engine/src/tests/import.rs +++ b/crates/engine/src/tests/import.rs @@ -1,7 +1,11 @@ //! Import: recursive folders, duplicate detection by content, add vs copy into the library. -use std::path::Path; +use std::{ + path::{Path, PathBuf}, + sync::atomic::AtomicUsize, +}; +use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; use crate::Session; @@ -1711,109 +1715,139 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { } fn smart_look_refresh_for(format: &str) { - use std::sync::atomic::{AtomicUsize, Ordering}; - let src = temp_dir(&format!("smartlook-src-{format}")); - let lib = temp_dir(&format!("smartlook-lib-{format}")); - write_png(&src.join("a.png"), 3); - write_png(&src.join("b.png"), 9); - let mut s = Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); - let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; - let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); - // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) - let mut raw = (**s.catalog.photo(id).unwrap()).clone(); - raw.format = format.into(); - raw.kind = lightcraft_catalog::MediaKind::Raw; - s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); - s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); - assert!(!s.catalog.photo(png).unwrap().relative_wb()); - s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); - let dir = s.media.smart_dir.clone().unwrap(); - let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); - let png_file = crate::smart::sharded_file_path(&dir, s.catalog.photo(png).unwrap()); - let (old, new) = (test_tone(1.5), test_tone(3.5)); - let calls = std::sync::Arc::new(AtomicUsize::new(0)); - let plain = s.media.file_loader.clone().unwrap(); - s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone())); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + let mut case = SmartLookCase::new(format); + assert_current_smart_look_is_not_rebuilt(&mut case); + let (old_bytes, png_bytes) = keep_offline_previews(&mut case); + refresh_online_previews(&mut case, &old_bytes, &png_bytes); + let _ = std::fs::remove_dir_all(&case.src); + let _ = std::fs::remove_dir_all(&case.lib); +} + +struct SmartLookCase { + src: PathBuf, + lib: PathBuf, + session: Session, + id: PhotoId, + dir: PathBuf, + file: PathBuf, + png_file: PathBuf, + old: lightcraft_pipeline::tone::CameraTone, + new: lightcraft_pipeline::tone::CameraTone, + calls: std::sync::Arc, + plain_loader: crate::media::FileLoader, +} + +impl SmartLookCase { + fn new(format: &str) -> Self { + let src = temp_dir(&format!("smartlook-src-{format}")); + let lib = temp_dir(&format!("smartlook-lib-{format}")); + write_png(&src.join("a.png"), 3); + write_png(&src.join("b.png"), 9); + let mut session = Session::new().with_fs(); + session.open_library(&lib, false).unwrap(); + session.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); + let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; + let (id, png_id) = (find(&session, "a.png"), find(&session, "b.png")); + // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) + let mut raw = (**session.catalog.photo(id).unwrap()).clone(); + raw.format = format.into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + session.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert_eq!(session.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); + assert!(!session.catalog.photo(png_id).unwrap().relative_wb()); + session.execute("library.select", &json!({"ids": [id.0, png_id.0], "active": id.0})).unwrap(); + let dir = session.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, session.catalog.photo(id).unwrap()); + let png_file = crate::smart::sharded_file_path(&dir, session.catalog.photo(png_id).unwrap()); + let (old, new) = (test_tone(1.5), test_tone(3.5)); + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let plain_loader = session.media.file_loader.clone().unwrap(); + session.media.file_loader = Some(look_loader(plain_loader.clone(), old, calls.clone())); + Self { src, lib, session, id, dir, file, png_file, old, new, calls, plain_loader } + } +} + +fn assert_current_smart_look_is_not_rebuilt(case: &mut SmartLookCase) { + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); - assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); - assert!(!crate::smart::is_stale(&file)); + assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version"); + assert!(!crate::smart::is_stale(&case.file)); // current header: kept as it is, the original is not even read - let before = std::fs::read(&file).unwrap(); - calls.store(0, Ordering::SeqCst); - s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone())); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + let before = std::fs::read(&case.file).unwrap(); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + case.session.media.file_loader = Some(look_loader(case.plain_loader.clone(), case.new, case.calls.clone())); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}"); - assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt"); - assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(std::fs::read(&case.file).unwrap(), before, "a current proxy is not rebuilt"); + assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 0); +} +fn keep_offline_previews(case: &mut SmartLookCase) -> (Vec, Vec) { // old headers (no field), originals offline: kept unchanged, and still marked stale - for f in [&file, &png_file] { + for f in [&case.file, &case.png_file] { edit_smart_header(f, |h| { h.as_object_mut().unwrap().remove("look_version"); }); } - assert!(crate::smart::is_stale(&file), "no field = version 0"); - let old_bytes = std::fs::read(&file).unwrap(); - let png_bytes = std::fs::read(&png_file).unwrap(); - std::fs::rename(&src, src.with_extension("offline")).unwrap(); - s.media.forget(id); - s.media.forget(png); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert!(crate::smart::is_stale(&case.file), "no field = version 0"); + let old_bytes = std::fs::read(&case.file).unwrap(); + let png_bytes = std::fs::read(&case.png_file).unwrap(); + std::fs::rename(&case.src, case.src.with_extension("offline")).unwrap(); + case.session.media.forget(case.id); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}"); assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); - assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched"); - assert!(crate::smart::is_stale(&file), "and it stays marked for the next time"); - assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there"); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes, "offline: the stored preview and curve are untouched"); + assert!(crate::smart::is_stale(&case.file), "and it stays marked for the next time"); + assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(case.old), "the old look is still there"); // ... and the opening refresh keeps it too, then marks the folder checked - assert!(!crate::smart::look_checked(&dir)); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert!(std::fs::read(&file).unwrap() == old_bytes); - assert!(crate::smart::look_checked(&dir)); + assert!(!crate::smart::look_checked(&case.dir)); + crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap(); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes); + assert!(crate::smart::look_checked(&case.dir)); + (old_bytes, png_bytes) +} +fn refresh_online_previews(case: &mut SmartLookCase, old_bytes: &[u8], png_bytes: &[u8]) { // the original comes back: the scan has run at this version, so the next opening leaves it; // Build Smart Previews rebuilds it with the current look - std::fs::rename(src.with_extension("offline"), &src).unwrap(); - calls.store(0, Ordering::SeqCst); - assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version"); - assert!(std::fs::read(&file).unwrap() == old_bytes); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}"); - assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); - assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look"); - assert!(!crate::smart::is_stale(&file)); - assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look"); + std::fs::rename(case.src.with_extension("offline"), &case.src).unwrap(); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none(), "checked once per version"); + assert_eq!(std::fs::read(&case.file).unwrap(), old_bytes); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(1), 1), "{r}"); + assert_eq!(smart_header(&case.file)["look_version"], json!(crate::camera_preview::LOOK_VERSION)); + assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, Some(case.new), "rebuilt with the current look"); + assert!(!crate::smart::is_stale(&case.file)); + assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes, "a PNG's proxy is never rebuilt for the look"); // a folder checked at an older version (or not at all) is scanned once at the next opening - edit_smart_header(&file, |h| h["look_version"] = json!(0)); - std::fs::write(dir.join("look-version"), b"0\n").unwrap(); - assert!(!crate::smart::look_checked(&dir)); - s.media.forget(id); - calls.store(0, Ordering::SeqCst); - crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap(); - assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read"); - assert!(!crate::smart::is_stale(&file)); - assert!(std::fs::read(&png_file).unwrap() == png_bytes); - assert!(crate::smart::look_checked(&dir)); + edit_smart_header(&case.file, |h| h["look_version"] = json!(0)); + std::fs::write(case.dir.join("look-version"), b"0\n").unwrap(); + assert!(!crate::smart::look_checked(&case.dir)); + case.session.media.forget(case.id); + case.calls.store(0, std::sync::atomic::Ordering::SeqCst); + crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).unwrap().join().unwrap(); + assert_eq!(case.calls.load(std::sync::atomic::Ordering::SeqCst), 1, "only the raw's original is read"); + assert!(!crate::smart::is_stale(&case.file)); + assert_eq!(std::fs::read(&case.png_file).unwrap(), png_bytes); + assert!(crate::smart::look_checked(&case.dir)); // once only - let rebuilt = std::fs::read(&file).unwrap(); - assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none()); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); - assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}"); - assert!(std::fs::read(&file).unwrap() == rebuilt); + let rebuilt = std::fs::read(&case.file).unwrap(); + assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut case.session).is_none()); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), case.calls.load(std::sync::atomic::Ordering::SeqCst)), (Some(0), 1), "{r}"); + assert_eq!(std::fs::read(&case.file).unwrap(), rebuilt); // Build Smart Previews refreshes a stale one the same way - edit_smart_header(&file, |h| h["look_version"] = json!(0)); - s.media.file_loader = Some(plain); - let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + edit_smart_header(&case.file, |h| h["look_version"] = json!(0)); + case.session.media.file_loader = Some(case.plain_loader.clone()); + let r = case.session.execute("library.smartPreviews", &json!({})).unwrap(); assert_eq!(r["refreshed"], 1, "{r}"); - assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); - let _ = std::fs::remove_dir_all(&src); - let _ = std::fs::remove_dir_all(&lib); + assert_eq!(crate::smart::decode(&std::fs::read(&case.file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept"); } /// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index 12dbadbd6..98290130e 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -43,8 +43,37 @@ pub struct Walked { pub entries: usize, } -fn hidden(p: &Path) -> bool { - p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) +/// Whether the entry has a dot-prefixed name or a Windows or macOS hidden attribute. +pub fn is_hidden_entry(entry: &std::fs::DirEntry) -> bool { + hidden_with_metadata(&entry.path(), || entry.metadata()) +} + +/// Whether the path has a dot-prefixed name or a Windows or macOS hidden attribute. +pub fn is_hidden_path(path: &Path) -> bool { + hidden_with_metadata(path, || std::fs::symlink_metadata(path)) +} + +fn hidden_with_metadata(path: &Path, metadata: impl FnOnce() -> std::io::Result) -> bool { + if path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + metadata().is_ok_and(|m| m.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0) + } + #[cfg(target_os = "macos")] + { + use std::os::darwin::fs::MetadataExt; + const UF_HIDDEN: u32 = 0x0000_8000; + metadata().is_ok_and(|m| m.st_flags() & UF_HIDDEN != 0) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = metadata; + false + } } /// What a folder's listing says an entry is. @@ -120,7 +149,7 @@ fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(& let p = e.path(); // (a hidden entry's kind is only asked for when the listing is wanted; every other // entry's is asked once) - let is_hidden = hidden(&p); + let is_hidden = is_hidden_entry(&e); if is_hidden && listed.is_none() { continue; } @@ -178,6 +207,34 @@ mod tests { d } + #[test] + fn hidden_names_and_platform_attributes_are_excluded_from_walks() { + let root = tmp("hidden-attributes"); + for folder in [".dot", "Hidden"] { + std::fs::create_dir_all(root.join(folder)).unwrap(); + std::fs::write(root.join(folder).join("inside.jpg"), b"x").unwrap(); + } + for file in ["Shown.jpg", ".dot.jpg", "hidden.jpg"] { + std::fs::write(root.join(file), b"x").unwrap(); + } + for name in ["Hidden", "hidden.jpg"] { + #[cfg(windows)] + assert!(std::process::Command::new("attrib").arg("+h").arg(root.join(name)).status().unwrap().success()); + #[cfg(target_os = "macos")] + assert!(std::process::Command::new("chflags").arg("hidden").arg(root.join(name)).status().unwrap().success()); + assert_eq!(is_hidden_path(&root.join(name)), cfg!(any(windows, target_os = "macos"))); + } + let mut found = files_in(&root, None, Limits::default(), |_| true).files; + found.sort(); + let mut expected = vec![root.join("Shown.jpg")]; + if !cfg!(any(windows, target_os = "macos")) { + expected.extend([root.join("Hidden/inside.jpg"), root.join("hidden.jpg")]); + } + expected.sort(); + assert_eq!(found, expected); + std::fs::remove_dir_all(root).unwrap(); + } + /// Given a folder with files, a folder inside it, a hidden file and a link, when it is walked /// with its listings noted, then the walk finds what it finds without them, and each folder's /// listing holds every entry by name with its kind (hidden ones too, a link as neither file nor diff --git a/crates/faces/src/align.rs b/crates/faces/src/align.rs index 4982325ae..387d028a6 100644 --- a/crates/faces/src/align.rs +++ b/crates/faces/src/align.rs @@ -19,6 +19,14 @@ pub struct Rgb<'a> { pub height: usize, } +/// Face bounds, padding factor, and output dimensions for box-based alignment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FaceCrop { + pub bounds: (f32, f32, f32, f32), + pub padding: f32, + pub output_size: (usize, usize), +} + impl Rgb<'_> { fn valid(&self) -> bool { self.width > 0 && self.height > 0 && self.width.checked_mul(self.height).and_then(|n| n.checked_mul(3)) == Some(self.data.len()) @@ -145,7 +153,10 @@ pub fn align_to_template(img: &Rgb, landmarks: &[(f32, f32); 5], out_w: usize, o } /// A square around a face box (pixels of `img`), `pad` times its larger side, as `out_w` × `out_h`. -pub fn crop_box(img: &Rgb, x0: f32, y0: f32, x1: f32, y1: f32, pad: f32, out_w: usize, out_h: usize) -> Option> { +pub fn crop_box(img: &Rgb, crop: FaceCrop) -> Option> { + let (x0, y0, x1, y1) = crop.bounds; + let pad = crop.padding; + let (out_w, out_h) = crop.output_size; if ![x0, y0, x1, y1, pad].iter().all(|v| v.is_finite()) { return None; } @@ -247,16 +258,16 @@ mod tests { } } let img = Rgb { data: &data, width: w, height: h }; - let out = crop_box(&img, 90.0, 40.0, 110.0, 60.0, 2.0, 64, 64).unwrap(); + let out = crop_box(&img, FaceCrop { bounds: (90.0, 40.0, 110.0, 60.0), padding: 2.0, output_size: (64, 64) }).unwrap(); // the 20 px block fills the middle half of the 64 px output let at = |x: usize, y: usize| out[(y * 64 + x) * 3 + 1]; assert!(at(32, 32) > 250 && at(20, 20) > 250 && at(44, 44) > 250 && at(5, 5) < 5 && at(60, 32) < 5); - assert!(crop_box(&img, 5.0, 5.0, 5.0, 5.0, 2.0, 64, 64).is_none(), "an empty box"); - assert!(crop_box(&img, f32::NAN, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none()); - assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 0, 64).is_none()); - assert!(crop_box(&img, 0.0, 0.0, 10.0, 10.0, 2.0, 100_000, 64).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (5.0, 5.0, 5.0, 5.0), padding: 2.0, output_size: (64, 64) }).is_none(), "an empty box"); + assert!(crop_box(&img, FaceCrop { bounds: (f32::NAN, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (0, 64) }).is_none()); + assert!(crop_box(&img, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (100_000, 64) }).is_none()); let bad = Rgb { data: &data[..10], width: w, height: h }; - assert!(crop_box(&bad, 0.0, 0.0, 10.0, 10.0, 2.0, 64, 64).is_none(), "wrong data length"); + assert!(crop_box(&bad, FaceCrop { bounds: (0.0, 0.0, 10.0, 10.0), padding: 2.0, output_size: (64, 64) }).is_none(), "wrong data length"); } #[test] @@ -264,7 +275,7 @@ mod tests { let data = vec![80u8; 50 * 50 * 3]; let img = Rgb { data: &data, width: 50, height: 50 }; // a box mostly outside the picture is padded by clamping, not an error - assert!(crop_box(&img, -30.0, -30.0, 20.0, 20.0, 1.5, 32, 32).is_some()); + assert!(crop_box(&img, FaceCrop { bounds: (-30.0, -30.0, 20.0, 20.0), padding: 1.5, output_size: (32, 32) }).is_some()); let far: [(f32, f32); 5] = [(1e6, 1e6), (1e6 + 5.0, 1e6), (1e6 + 2.0, 1e6 + 3.0), (1e6, 1e6 + 6.0), (1e6 + 5.0, 1e6 + 6.0)]; let _ = align_to_template(&img, &far, 112, 112); let tiny: [(f32, f32); 5] = [(1.0, 1.0), (1.01, 1.0), (1.0, 1.01), (1.01, 1.01), (1.005, 1.005)]; diff --git a/crates/faces/src/net/kernels.rs b/crates/faces/src/net/kernels.rs index 71ae95874..6ad7e83dd 100644 --- a/crates/faces/src/net/kernels.rs +++ b/crates/faces/src/net/kernels.rs @@ -1,5 +1,5 @@ //! Safe CPU kernels shared by detection and recognition. No inner thread pool. -use super::*; +use super::{ConvJob, Graph, MAX_TENSOR, NetError, Node, Result, Tensor, chw, conv_direct, pads, pair, program, shape, shape_err}; use ndarray::{ArrayView2, ArrayViewMut2, linalg::general_mat_mul}; const BLOCK: usize = 256; #[derive(Default)] @@ -30,7 +30,16 @@ fn multiply(w: &[f32], x: &[f32], y: &mut [f32], m: usize, k: usize, n: usize) - } /// For equal-width, unit-stride maps a shifted plane is one contiguous copy. Only horizontal /// border stripes need masking; clipping the flat source range also removes vertical padding. -fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, pl: usize, start: usize, row: &mut [f32]) -> Result<()> { +#[derive(Clone, Copy)] +struct PlaneShift { + width: usize, + kernel: (usize, usize), + padding: (usize, usize), + start: usize, +} + +fn shifted_plane(plane: &[f32], row: &mut [f32], shift: PlaneShift) -> Result<()> { + let PlaneShift { width, kernel: (ky, kx), padding: (pt, pl), start } = shift; let end = start + row.len(); let delta = (ky as i128 - pt as i128) * width as i128 + kx as i128 - pl as i128; let lo = (-delta).clamp(start as i128, end as i128); @@ -145,13 +154,8 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re if sh == 1 && sw == 1 && ow == w { shifted_plane( plane, - w, - ky, - kx, - pt, - pl, - start, row.get_mut(..len).ok_or_else(|| NetError::Shape("short lower panel".into()))?, + PlaneShift { width: w, kernel: (ky, kx), padding: (pt, pl), start }, )?; continue; } diff --git a/crates/faces/src/net/program.rs b/crates/faces/src/net/program.rs index e00e51da1..db7dcba5d 100644 --- a/crates/faces/src/net/program.rs +++ b/crates/faces/src/net/program.rs @@ -9,7 +9,7 @@ use std::borrow::Cow; -use super::*; +use super::{ConvJob, Data, HashMap, Net, NetError, Result, kernels, pads, pair, shape, shape_err, unsupported}; /// Value 0 is the network's input. pub const INPUT: usize = 0; diff --git a/crates/faces/src/net/shape.rs b/crates/faces/src/net/shape.rs index a1594ca6b..39e585c61 100644 --- a/crates/faces/src/net/shape.rs +++ b/crates/faces/src/net/shape.rs @@ -1,5 +1,5 @@ //! Bounded, allocation-free (apart from small shape lists) graph checking. -use super::*; +use super::{Data, Graph, HashMap, MAX_TENSOR, NetError, Node, Result, pads, pair, shape_err, unsupported}; use crate::graph::{Attr, Weight}; pub(super) fn elements(s: &[usize]) -> Result { diff --git a/crates/geocode/src/unzip.rs b/crates/geocode/src/unzip.rs index f78648d83..d422297a7 100644 --- a/crates/geocode/src/unzip.rs +++ b/crates/geocode/src/unzip.rs @@ -61,7 +61,13 @@ pub fn extract(zip: &[u8], name: &str, max: usize) -> Result, GeocodeErr return match method { 0 if data.len() <= max => Ok(data.to_vec()), 0 => Err(GeocodeError::Invalid(format!("{name} is larger than {max} bytes"))), - 8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, max).map_err(|e| bad(&format!("deflate: {e:?}"))), + 8 => { + let decoded = miniz_oxide::inflate::decompress_to_vec_with_limit(data, max).map_err(|e| bad(&format!("deflate: {e:?}")))?; + if decoded.len() != usize_ { + return Err(bad("uncompressed size")); + } + Ok(decoded) + } m => Err(bad(&format!("compression method {m}"))), }; } @@ -120,4 +126,15 @@ mod tests { let _ = extract(&bad, "cities15000.txt", 100); } } + + #[test] + fn rejects_deflated_entry_with_inconsistent_directory_size() { + let mut z = make("cities15000.txt", b"hello world"); + let eocd = z.len() - 22; + let cd = u32::from_le_bytes([z[eocd + 16], z[eocd + 17], z[eocd + 18], z[eocd + 19]]) as usize; + let declared_size = cd + 24; + z[declared_size..declared_size + 4].copy_from_slice(&2u32.to_le_bytes()); + + assert!(extract(&z, "cities15000.txt", 100).is_err()); + } } diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 33e02fbe4..7bbbbc68d 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -499,11 +499,31 @@ impl Gpu { self.over_limit(bytes.len() as u64); return Buf { buf: Some(Tracked::new(self.placeholder())), len }; } - let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: None, - contents: bytes, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, - }); + let usage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST; + if let Some(size) = wgpu::BufferSize::new(bytes.len() as u64).filter(|s| s.get() % wgpu::COPY_BUFFER_ALIGNMENT == 0) { + // through the queue: the bytes are copied once, into a staging buffer the next submission copies from, in + // parallel parts (`create_buffer_init` zeroes a staging buffer first and copies over that, two passes on + // one thread over a full-size photo's ~290 MB) + let buf = self.device.create_buffer(&wgpu::BufferDescriptor { label: None, size: size.get(), usage, mapped_at_creation: false }); + if let Some(mut view) = self.queue.write_buffer_with(&buf, 0, size) { + const PART: usize = 1 << 20; + { + let (whole, mut tail) = view.slice(..).into_chunks::(); + let (src, src_tail) = bytes.as_chunks::(); + let mut parts: Vec<_> = whole.into_iter().zip(src).map(Some).collect(); + lightcraft_raster::par_rows(&mut parts, 1, |_, part| { + for (d, s) in part.iter_mut().filter_map(Option::take) { + wgpu::WriteOnly::<[u8]>::from(d).copy_from_slice(s); + } + }); + tail.copy_from_slice(src_tail); + } + // (dropping the view schedules the copy) + drop(view); + return Buf { buf: Some(Tracked::new(buf)), len }; + } + } + let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: bytes, usage }); Buf { buf: Some(Tracked::new(buf)), len } } @@ -612,7 +632,17 @@ impl Gpu { Ok(d) => d, Err(e) => return failed(format!("{e:?}")), }; - let out: Vec = bytemuck::cast_slice(&data).to_vec(); + // copied out in parallel parts: one thread reads mapped memory slowly (~2 GB/s, so ~45 ms for a 24 MP + // export's pixels), and the new vector's pages are first touched there too + let src: &[u8] = &data; + // (`len` counts 32-bit words: as many `T` as they fill) + let mut out = vec![::zeroed(); src.len() / std::mem::size_of::().max(1)]; + const PART: usize = 1 << 20; + lightcraft_raster::par_rows(bytemuck::cast_slice_mut::(&mut out), PART, |k, part| { + if let Some(s) = src.get(k * PART..k * PART + part.len()) { + part.copy_from_slice(s); + } + }); drop(data); staging.unmap(); out @@ -811,6 +841,26 @@ mod tests { Some(stdout) } + /// An upload reaches the device whole: it is copied into staging memory in parallel parts, the last one + /// shorter than the others (and a small upload is that last part alone). + #[test] + fn uploads_read_back_exactly() { + let Some(g) = crate::device() else { + lightcraft_testkit::skip!(Gpu, "no GPU device"); + return; + }; + for len in [1usize, 1000, (3 << 20) / 4 + 777] { + let data: Vec = (0..len as u32).map(|i| i.wrapping_mul(2_654_435_761)).collect(); + let b = g.upload(&data); + let back: Vec = g.finish_and_read(g.encoder(), &b, len); + assert!(back == data, "{len} values"); + } + // read back as wider values (`len` counts 32-bit words), as `read_rgb` does + let data: Vec = (0..3 << 20).collect(); + let b = g.upload(&data); + let back: Vec<[u32; 3]> = g.finish_and_read(g.encoder(), &b, data.len()); + assert!(back.len() == 1 << 20 && back.as_flattened() == data.as_slice()); + } /// Issue #1858: once a Vulkan or DX12 device is lost, wgpu 30 panics in `Device::poll` instead of /// returning an error. A render's readback panicked; while it unwound, a buffer's way back to /// the pool polls again from a destructor, and that second panic aborted the app. diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 55420598b..c5df00a77 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -235,32 +235,35 @@ fn round_up(v: usize, to: usize) -> usize { v.div_ceil(to) * to } -/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells. -fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec), String> { +fn validate_plan(net: &Net, tile: usize, ops: &[Op]) -> Result<(), String> { if !net.fits_tile(tile) { return unsupported("the tile does not fit the network"); } if tile > MAX_TILE { return unsupported("the tile is larger than the kernels' dispatches reach"); } + if !net.in_channels.is_multiple_of(4) { + return unsupported("the input channel count is not a multiple of 4"); + } + let count = ops.len(); + let ends_right = matches!( + (count.checked_sub(2).and_then(|i| ops.get(i)), ops.last()), + (Some(Op::Conv(conv)), Some(Op::DepthToSpace2 { src })) + if conv.k == 1 && conv.src2.is_none() && *src == count - 1 && conv.cout == 12 && conv.leaky.is_none() + ); + if ends_right { Ok(()) } else { unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space") } +} + +/// Work out the steps, the buffers and the arranged weights for `net` on `tile × tile` input cells. +fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec), String> { + let ops = net.ops(); + validate_plan(net, tile, ops)?; let side = |t: usize| -> usize { let level = net.shape(t).map_or(0, |s| s.level); if level >= 0 { tile >> level } else { tile << (-level) } }; let chan = |t: usize| net.shape(t).map_or(0, |s| s.channels); - if !net.in_channels.is_multiple_of(4) { - return unsupported("the input channel count is not a multiple of 4"); - } - let ops = net.ops(); - // the network must end in a 1 × 1 convolution and a depth-to-space (they run as one kernel) let n_ops = ops.len(); - let ends_right = matches!( - (n_ops.checked_sub(2).and_then(|i| ops.get(i)), ops.last()), - (Some(Op::Conv(c)), Some(Op::DepthToSpace2 { src })) if c.k == 1 && c.src2.is_none() && *src == n_ops - 1 && c.cout == 12 && c.leaky.is_none() - ); - if !ends_right { - return unsupported("the network does not end in a 1 × 1 convolution to 12 channels and depth-to-space"); - } // the last layer that reads each tensor let mut last_use = vec![0usize; n_ops + 1]; for (i, op) in ops.iter().enumerate() { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 64990db0e..90aed7974 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use lightcraft_develop::DevelopSettings; use lightcraft_geom::Orientation; -use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, mask_terms}; +use lightcraft_pipeline::finish::{FinishParams, FinishParamsInput, MASK_TERMS, mask_terms}; use lightcraft_pipeline::geometry::SampleMode; use lightcraft_pipeline::local::plane_at; use lightcraft_pipeline::{Plan, RenderRequest, Rendered, SourceInfo, local}; @@ -668,7 +668,15 @@ pub fn render( lap("masks", &mut t, &mut cx); // 5. per-pixel stage - let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + let mut fp = FinishParams::new(FinishParamsInput { + settings: s, + frame: &plan.frame, + info, + image_size: (w, h), + px_per_long: plan.px_per_long, + air_pre: prep.air, + space: 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 { diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs index 226604303..6bc629fa5 100644 --- a/crates/heif/src/vui.rs +++ b/crates/heif/src/vui.rs @@ -201,6 +201,11 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option Option { let r = &mut Bits { data: rbsp, pos: 0 }; let (_, _, max_sub_layers_minus1) = sps_size(r)?; + parse_sps_parameters(r, max_sub_layers_minus1)?; + parse_vui(r) +} + +fn parse_sps_parameters(r: &mut Bits<'_>, max_sub_layers_minus1: u32) -> Option<()> { if r.flag()? { for _ in 0..4 { r.ue()?; // conf_win_*_offset @@ -253,6 +258,10 @@ fn parse_sps(rbsp: &[u8]) -> Option { } } r.skip(2)?; // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag + Some(()) +} + +fn parse_vui(r: &mut Bits<'_>) -> Option { let mut signal = Signal::default(); if !r.flag()? { return Some(signal); // no VUI diff --git a/crates/lensdb/src/tests.rs b/crates/lensdb/src/tests.rs index 435882381..6411ffd66 100644 --- a/crates/lensdb/src/tests.rs +++ b/crates/lensdb/src/tests.rs @@ -84,6 +84,11 @@ fn parses_cameras_lenses_and_skips_invalid_entries() { assert!(!db.lenses[2].rectilinear); } +#[test] +fn rejects_mismatched_xml_closing_tags() { + assert!(LensDb::parse("XL").is_err()); +} + #[test] fn malformed_documents_are_errors_not_panics() { assert!(LensDb::parse("").is_err()); diff --git a/crates/lensdb/src/xml.rs b/crates/lensdb/src/xml.rs index dc611a647..0ff0c742c 100644 --- a/crates/lensdb/src/xml.rs +++ b/crates/lensdb/src/xml.rs @@ -74,7 +74,6 @@ fn unescape(s: &str) -> String { pub(crate) fn parse(s: &str) -> Result { let mut reader = quick_xml::Reader::from_str(s); - reader.config_mut().check_end_names = false; let mut stack = vec![Node { name: "#root".into(), ..Default::default() }]; let mut count = 0usize; loop { diff --git a/crates/mcp/tests/it/e2e.rs b/crates/mcp/tests/it/e2e.rs index 03d77c7e2..2a8db44c7 100644 --- a/crates/mcp/tests/it/e2e.rs +++ b/crates/mcp/tests/it/e2e.rs @@ -246,33 +246,13 @@ impl Backend for HoldAfterFirst { } } -/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation -/// between complete photos, unrelated output preservation, and a usable session afterwards. -/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or -/// the cancel is read, so neither can finish first on a fast machine. -#[test] -fn export_progress_and_cancel() { - let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let (input, mut send) = std::io::pipe().unwrap(); - let (output, write) = std::io::pipe().unwrap(); - let hold = Arc::new(AtomicBool::new(true)); - let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() }; - let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap()); - let (tx, rx) = std::sync::mpsc::channel(); - let reader = std::thread::spawn(move || { - for line in BufReader::new(output).lines() { - let value: Value = serde_json::from_str(&line.unwrap()).unwrap(); - if tx.send(value).is_err() { - break; - } - } - }); - let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress"); - writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap(); - let query = next(); - let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap(); +type Rx = std::sync::mpsc::Receiver; + +fn next_msg(rx: &Rx) -> Value { + rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress") +} + +fn assert_query_photos(photos: &[Value]) { assert_eq!(photos.len(), 8); // Real files, real metadata: eight distinct 1200×800 PNGs probed from disk, not synthesized. let names: std::collections::HashSet<&str> = photos.iter().map(|p| p["fileName"].as_str().unwrap()).collect(); @@ -281,13 +261,15 @@ fn export_progress_and_cancel() { assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1200), Some(800)), "{p}"); assert_eq!(p["format"], "PNG", "{p}"); } - let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect(); +} + +fn run_complete_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], dir: &std::path::Path) { let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"})); request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"}); writeln!(send, "{request}").unwrap(); let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false); loop { - let value = next(); + let value = next_msg(rx); if value["method"] == "notifications/progress" { assert_eq!(value["params"]["progressToken"], "photos"); let progress = value["params"]["progress"].as_f64().unwrap(); @@ -312,17 +294,16 @@ fn export_progress_and_cancel() { } assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}"); assert_eq!(last, 8.0); - let out = dir.join("cancelled"); - std::fs::create_dir_all(&out).unwrap(); - let decoy = out.join("photo999.png"); - std::fs::write(&decoy, b"unrelated existing output").unwrap(); +} + +fn run_cancelled_export(send: &mut impl Write, rx: &Rx, hold: &AtomicBool, ids: &[Value], out: &std::path::Path) { // this export waits after its first photo until the cancel below is read hold.store(true, Ordering::SeqCst); let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}})); request["params"]["_meta"] = json!({"progressToken":44}); writeln!(send, "{request}").unwrap(); loop { - let value = next(); + let value = next_msg(rx); assert_ne!(value["id"], 4, "must be cancellable before completion: {value}"); assert_eq!(value["params"]["progressToken"], 44, "{value}"); if value["params"]["progress"].as_f64().unwrap() > 0.0 { @@ -332,7 +313,7 @@ fn export_progress_and_cancel() { writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap(); writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap(); loop { - let value = next(); + let value = next_msg(rx); if value["method"] == "notifications/progress" { continue; } @@ -340,13 +321,19 @@ fn export_progress_and_cancel() { assert_eq!(value["result"]["isError"], false); break; } - assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output"); - let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect(); +} + +fn assert_cancelled_output(out: &std::path::Path, decoy: &std::path::Path) { + assert_eq!(std::fs::read(decoy).unwrap(), b"unrelated existing output"); + let photos: Vec<_> = std::fs::read_dir(out).unwrap().map(|e| e.unwrap().path()).filter(|p| p != decoy).collect(); assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}"); for path in &photos { let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file"); assert_eq!((d.width, d.height), (1200, 800), "{}", path.display()); } +} + +fn assert_complete_output(dir: &std::path::Path) { // The finished export's eight outputs are real 1200×800 PNGs, one per distinct original. let complete: Vec<_> = std::fs::read_dir(dir.join("complete")).unwrap().map(|e| e.unwrap().path()).collect(); assert_eq!(complete.len(), 8, "earlier export retained: {complete:?}"); @@ -359,6 +346,44 @@ fn export_progress_and_cancel() { exported.insert(bytes); } assert_eq!(exported.len(), 8, "eight distinct outputs, not copies of one export"); +} + +/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation +/// between complete photos, unrelated output preservation, and a usable session afterwards. +/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or +/// the cancel is read, so neither can finish first on a fast machine. +#[test] +fn export_progress_and_cancel() { + let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (input, mut send) = std::io::pipe().unwrap(); + let (output, write) = std::io::pipe().unwrap(); + let hold = Arc::new(AtomicBool::new(true)); + let backend = HoldAfterFirst { inner: file_backed_photos(&dir), hold: hold.clone() }; + let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(output).lines() { + let value: Value = serde_json::from_str(&line.unwrap()).unwrap(); + if tx.send(value).is_err() { + break; + } + } + }); + writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap(); + let query = next_msg(&rx); + let photos = query["result"]["structuredContent"]["photos"].as_array().unwrap(); + assert_query_photos(photos); + let ids: Vec<_> = photos.iter().map(|p| p["id"].clone()).collect(); + run_complete_export(&mut send, &rx, &hold, &ids, &dir); + let out = dir.join("cancelled"); + std::fs::create_dir_all(&out).unwrap(); + let decoy = out.join("photo999.png"); + std::fs::write(&decoy, b"unrelated existing output").unwrap(); + run_cancelled_export(&mut send, &rx, &hold, &ids, &out); + assert_cancelled_output(&out, &decoy); + assert_complete_output(&dir); drop(send); // The fixture's files stay in place until the server (which owns the session) and the reader // have finished: the session reads the originals; the output is what the checks above read. diff --git a/crates/meta/src/iptc.rs b/crates/meta/src/iptc.rs index 24955748f..391774287 100644 --- a/crates/meta/src/iptc.rs +++ b/crates/meta/src/iptc.rs @@ -8,8 +8,8 @@ use crate::{DateTime, Metadata}; fn decode(b: &[u8], utf8: bool) -> String { match std::str::from_utf8(b) { Ok(s) => s.trim_end_matches('\0').trim().to_string(), - Err(_) if utf8 => String::from_utf8_lossy(b).trim().to_string(), - Err(_) => b.iter().map(|&c| c as char).collect::().trim().to_string(), + Err(_) if utf8 => String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string(), + Err(_) => b.iter().map(|&c| c as char).collect::().trim_end_matches('\0').trim().to_string(), } } @@ -106,6 +106,20 @@ pub(crate) mod tests { assert_eq!(m.capture_time.unwrap().to_iso(), "2021-03-04T10:11:12+01:00"); } + #[test] + fn non_utf8_text_drops_trailing_nul() { + let mut v = Vec::new(); + ds(&mut v, 2, 5, &[0x43, 0xe9, 0x00]); + ds(&mut v, 2, 25, &[0x6b, 0xe9, 0x00]); + let m = parse_iptc(&v); + assert_eq!(m.title.as_deref(), Some("Cé")); + assert_eq!(m.keywords, vec!["ké".to_string()]); + let mut u = Vec::new(); + ds(&mut u, 1, 90, b"\x1b%G"); + ds(&mut u, 2, 5, &[0x43, 0xff, 0x00]); + assert_eq!(parse_iptc(&u).title.as_deref(), Some("C\u{fffd}")); + } + #[test] fn latin1_and_extended_and_garbage() { let mut v = Vec::new(); diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 46c7a1043..07887bbdc 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -187,7 +187,16 @@ impl Metadata { orientation, gps, width, - height, + height + ); + self.fill_missing_descriptive(other); + } + + /// Fill descriptive metadata separately from the camera and image properties. + fn fill_missing_descriptive(&mut self, other: &Metadata) { + fill!( + self, + other, artist, copyright, copyright_marked, @@ -405,11 +414,22 @@ mod tests { #[test] fn merge_rules() { - let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() }; - let b = Metadata { make: Some("B".into()), model: Some("M".into()), rating: Some(4), keywords: vec!["k".into()], ..Default::default() }; + let mut a = Metadata { make: Some("A".into()), title: Some("Original".into()), rating: Some(1), ..Default::default() }; + let b = Metadata { + make: Some("B".into()), + model: Some("M".into()), + title: Some("Replacement".into()), + caption: Some("Caption".into()), + rating: Some(4), + keywords: vec!["k".into()], + ..Default::default() + }; a.fill_missing(&b); assert_eq!(a.make.as_deref(), Some("A")); assert_eq!(a.model.as_deref(), Some("M")); + assert_eq!(a.title.as_deref(), Some("Original")); + assert_eq!(a.caption.as_deref(), Some("Caption")); + assert_eq!(a.keywords, vec!["k".to_string()]); assert_eq!(a.rating, Some(1)); a.overlay_user_fields(&b); assert_eq!(a.rating, Some(4)); diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index c1e6a9bc2..4fccd1134 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -186,19 +186,20 @@ pub struct FinishParams { pub peak: f32, } +pub struct FinishParamsInput<'a> { + pub settings: &'a DevelopSettings, + pub frame: &'a Frame, + pub info: &'a SourceInfo, + pub image_size: (usize, usize), + pub px_per_long: f64, + pub air_pre: f32, + pub space: OutputSpace, +} + impl FinishParams { /// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`]. - #[allow(clippy::too_many_arguments)] - pub fn new( - s: &DevelopSettings, - frame: &Frame, - info: &SourceInfo, - w: usize, - h: usize, - px_per_long: f64, - air_pre: f32, - space: OutputSpace, - ) -> FinishParams { + pub fn new(input: FinishParamsInput<'_>) -> FinishParams { + let FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long, air_pre, space } = input; let effects = s.section_enabled("effects"); let (clar, tex, dehaze) = if effects { ((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32) @@ -288,7 +289,8 @@ pub(crate) fn finish( proof: Option, ) -> Rgba8 { let (w, h) = (p.img.width, p.img.height); - let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let mut fp = + FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space }); fp.proof = proof.map(|pr| pr.params(space)); if let Some(d) = display { fp.for_display(d, proof); @@ -305,7 +307,6 @@ pub(crate) fn finish( } /// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries). -#[allow(clippy::too_many_arguments)] pub(crate) fn finish_deep( p: &Prepared, s: &DevelopSettings, @@ -317,7 +318,8 @@ pub(crate) fn finish_deep( ) -> DeepImage { use lightcraft_color::transfer::srgb_to_linear; let (w, h) = (p.img.width, p.img.height); - let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let mut fp = + FinishParams::new(FinishParamsInput { settings: s, frame, info, image_size: (w, h), px_per_long: p.px_per_long, air_pre: p.air, space }); fp.proof = proof.map(|pr| pr.params(space)); let trc = fp.out_trc; let samples = match depth { @@ -354,29 +356,7 @@ pub(crate) fn finish_with( ) -> Vec { let (w, h) = (p.img.width, p.img.height); let p_lut = fp.lut.clone(); - let FinishParams { - tone, - ops, - curves, - vig, - to_out, - out_luma, - hl, - sh, - clar, - tex, - dehaze, - sharpen, - sharpen_mask, - air, - air_pre, - gain, - ev, - grain, - .. - } = fp; - let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) = - (*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev); + let FinishParams { curves, vig, to_out, out_luma, grain, .. } = fp; let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); let out_to_norm = fp.out_to_norm; let long = fp.ow.max(fp.oh); @@ -392,128 +372,17 @@ pub(crate) fn finish_with( for (x, px) in row.iter_mut().enumerate() { let i = y * w + x; let raw = p.img.data[i]; - let mut c = if gain == 1.0 { raw } else { raw.map(|v| v * gain) }; + let local = pixel_local_terms(p, &terms, i); + let l_sat = local.values[11]; + let l_hue = local.values[12]; let l_pre = p.log_l.data[i]; - let l0 = l_pre + ev; - - // --- local (mask) contributions: alpha-weighted sums of the masks' terms - let mut l = [0.0f32; MASK_SUMS]; - let mut tint_col: Option<([f32; 3], f32)> = None; - for (m, t) in p.masks.iter().zip(&terms) { - let a = m.alpha.data[i]; - if a <= 0.0 { - continue; - } - for k in 0..MASK_SUMS { - l[k] += a * t[k]; - } - if t[MASK_SUMS] > 0.0 { - tint_col = Some(([t[MASK_SUMS + 1], t[MASK_SUMS + 2], 0.0], a * t[MASK_SUMS + 3])); - } - } - let [l_exp, l_temp, l_tint, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, l_dehaze, l_sat, l_hue, l_sharp, l_noise, l_moire, l_defringe] = - l; - - // --- local Moiré (and the colour part of Noise): chromaticity towards its blur - let mt = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0); - if mt != 0.0 - && let Some(cb) = &p.chroma_blur - { - let (y, ch0, cb) = (luminance_2020(c), crate::masks::chromaticity(raw), cb.data[i]); - c = std::array::from_fn(|k| ((ch0[k] + (cb[k] - ch0[k]) * mt) * y).max(0.0)); - } - // --- local Defringe: desaturate purple / green fringes along edges - let df = l_defringe.clamp(0.0, 1.0); - if df > 0.0 - && let Some(b) = &p.texture_blur - { - let k = df * defringe_weight(c, p.log_l.data[i] - b.data[i]); - if k > 0.0 { - let y = luminance_2020(c); - c = c.map(|v| v + (y - v) * k); - } - } - - // --- dehaze (scene linear) - let dz = dehaze + l_dehaze; - if dz != 0.0 - && let Some(dark) = &p.dark - { - // 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)); - } else { - let k = (-dz).min(1.0) * 0.7 * (0.35 + 0.65 * d); - c = c.map(|v| v + (air * 0.9 - v) * k); - } - } - - // --- local exposure / temp / tint - if l_exp != 0.0 { - let g = l_exp.exp2(); - c = c.map(|v| v * g); - } - if l_temp != 0.0 || l_tint != 0.0 { - let y0 = luminance_2020(c); - c = [c[0] * (1.0 + 0.3 * l_temp), c[1] * (1.0 - 0.22 * l_tint), c[2] * (1.0 - 0.3 * l_temp).max(0.0)]; - let y1 = luminance_2020(c).max(1e-9); - c = c.map(|v| v * y0 / y1); - } + let l0 = l_pre + fp.ev; + let (mut c, l1) = adjust_scene_pixel(p, fp, i, raw, &local); // --- local tone in log luminance - let l1 = if dz != 0.0 || l_exp != 0.0 { log_lum(c) } else { l0 }; let shift = l1 - l0; - let base = p.base.data[i] + ev + shift; - let mut delta = 0.0f32; - let (hh, ss) = (hl + l_hl, sh + l_sh); - if hh != 0.0 || ss != 0.0 { - let ws = 1.0 - smooth(-4.8, 0.3, base); - let wh = smooth(-1.0, 2.8, base); - delta += ss * 1.7 * ws * ws.sqrt() + hh * 1.7 * wh; - } - if l_wh != 0.0 { - delta += l_wh * 0.8 * smooth(0.5, 3.0, l1); - } - if l_bl != 0.0 { - delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1)); - } - if l_con != 0.0 { - delta += l_con * 0.14 * l1.clamp(-6.0, 4.0); - } - let cl = clar + l_clar; - if cl != 0.0 - && let Some(b) = &p.clarity_blur - { - let det = (l_pre - b.data[i]).clamp(-2.5, 2.5); - let mid = (-(base / 3.2).powi(2)).exp(); - delta += cl * 0.85 * det * (0.35 + 0.65 * mid); - } - let tx = tex + l_tex; - let sp = l_sharp * 0.6 + sharpen; - if (tx != 0.0 || sp != 0.0) - && let Some(b) = &p.texture_blur - { - let det = l_pre - b.data[i]; - let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs()); - delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame; - if sp != 0.0 { - let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 }; - delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; - } - } - // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges - if l_noise != 0.0 - && let Some(b) = &p.texture_blur - { - let det = l_pre - b.data[i]; - delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * det * (1.0 - smooth(0.1, 0.5, det.abs())); - } + let base = p.base.data[i] + fp.ev + shift; + let delta = local_tone_delta(p, fp, i, base, l1, &local); if delta != 0.0 { let g = delta.exp2(); c = c.map(|v| v * g); @@ -526,9 +395,9 @@ pub(crate) fn finish_with( // --- tone map on luminance, highlight desaturation let yl = luminance_2020(c); - let o = tone.apply(yl); + let o = fp.tone.apply(yl); let mut d = if yl > 1e-9 { c.map(|v| v * o / yl) } else { [0.0; 3] }; - let k = tone.chroma_scale(o); + let k = fp.tone.chroma_scale(o); if k != 1.0 { d = d.map(|v| o + (v - o) * k); } @@ -539,8 +408,8 @@ pub(crate) fn finish_with( } // --- colour - d = ops.apply(d, l_sat, l_hue); - if let Some((dir, amt)) = tint_col { + d = fp.ops.apply(d, l_sat, l_hue); + if let Some((dir, amt)) = local.tint { let lab = lightcraft_color::perceptual::oklab_from_2020(d); d = lightcraft_color::perceptual::oklab_to_2020([lab[0], lab[1] + dir[0] * 0.08 * amt, lab[2] + dir[1] * 0.08 * amt]); } @@ -663,6 +532,134 @@ pub(crate) fn finish_with( out } +struct PixelLocalTerms { + values: [f32; MASK_SUMS], + tint: Option<([f32; 3], f32)>, +} + +fn pixel_local_terms(p: &Prepared, terms: &[[f32; MASK_TERMS]], index: usize) -> PixelLocalTerms { + let mut values = [0.0; MASK_SUMS]; + let mut tint = None; + for (mask, terms) in p.masks.iter().zip(terms) { + let alpha = mask.alpha.data[index]; + if alpha <= 0.0 { + continue; + } + for (sum, term) in values.iter_mut().zip(terms.iter().take(MASK_SUMS)) { + *sum += alpha * term; + } + if terms[MASK_SUMS] > 0.0 { + tint = Some(([terms[MASK_SUMS + 1], terms[MASK_SUMS + 2], 0.0], alpha * terms[MASK_SUMS + 3])); + } + } + PixelLocalTerms { values, tint } +} + +fn adjust_scene_pixel(p: &Prepared, fp: &FinishParams, index: usize, raw: [f32; 3], local: &PixelLocalTerms) -> ([f32; 3], f32) { + let [l_exp, l_temp, l_tint, _, _, _, _, _, _, _, l_dehaze, _, _, _, l_noise, l_moire, l_defringe] = local.values; + let mut color = if fp.gain == 1.0 { raw } else { raw.map(|value| value * fp.gain) }; + + let moire = (l_moire + 0.5 * l_noise.max(0.0)).clamp(-1.0, 1.0); + if moire != 0.0 + && let Some(blur) = &p.chroma_blur + { + let (luminance, original, blurred) = (luminance_2020(color), crate::masks::chromaticity(raw), blur.data[index]); + color = std::array::from_fn(|channel| ((original[channel] + (blurred[channel] - original[channel]) * moire) * luminance).max(0.0)); + } + let defringe = l_defringe.clamp(0.0, 1.0); + if defringe > 0.0 + && let Some(blur) = &p.texture_blur + { + let amount = defringe * defringe_weight(color, p.log_l.data[index] - blur.data[index]); + if amount > 0.0 { + let luminance = luminance_2020(color); + color = color.map(|value| value + (luminance - value) * amount); + } + } + + let dehaze = fp.dehaze + l_dehaze; + if dehaze != 0.0 + && let Some(dark) = &p.dark + { + // Limit the estimated veil to the pixel's own minimum channel. This avoids crushing bright + // neighbours when the dark-channel blur spans the edge of a local window. + let min_channel = color[0].min(color[1]).min(color[2]); + let veil = (if fp.air_pre > 0.0 { dark.data[index] / fp.air_pre } else { 0.0 }) + .min(if fp.air > 0.0 { min_channel / fp.air } else { 0.0 }) + .clamp(0.0, 1.0); + if dehaze > 0.0 { + let transmission = (1.0 - 0.95 * dehaze.min(1.0) * veil).max(0.12); + color = color.map(|value| ((value - fp.air * (1.0 - transmission)) / transmission).max(0.0)); + } else { + let amount = (-dehaze).min(1.0) * 0.7 * (0.35 + 0.65 * veil); + color = color.map(|value| value + (fp.air * 0.9 - value) * amount); + } + } + + if l_exp != 0.0 { + let gain = l_exp.exp2(); + color = color.map(|value| value * gain); + } + if l_temp != 0.0 || l_tint != 0.0 { + let luminance_before = luminance_2020(color); + color = [color[0] * (1.0 + 0.3 * l_temp), color[1] * (1.0 - 0.22 * l_tint), color[2] * (1.0 - 0.3 * l_temp).max(0.0)]; + let luminance_after = luminance_2020(color).max(1e-9); + color = color.map(|value| value * luminance_before / luminance_after); + } + + let l0 = p.log_l.data[index] + fp.ev; + let log_luminance = if dehaze != 0.0 || l_exp != 0.0 { log_lum(color) } else { l0 }; + (color, log_luminance) +} + +fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1: f32, local: &PixelLocalTerms) -> f32 { + let [_, _, _, l_con, l_hl, l_sh, l_wh, l_bl, l_tex, l_clar, _, _, _, l_sharp, l_noise, _, _] = local.values; + let mut delta = 0.0; + let (highlights, shadows) = (fp.hl + l_hl, fp.sh + l_sh); + if highlights != 0.0 || shadows != 0.0 { + let shadow_weight = 1.0 - smooth(-4.8, 0.3, base); + let highlight_weight = smooth(-1.0, 2.8, base); + delta += shadows * 1.7 * shadow_weight * shadow_weight.sqrt() + highlights * 1.7 * highlight_weight; + } + if l_wh != 0.0 { + delta += l_wh * 0.8 * smooth(0.5, 3.0, l1); + } + if l_bl != 0.0 { + delta += l_bl * 0.8 * (1.0 - smooth(-6.0, -1.5, l1)); + } + if l_con != 0.0 { + delta += l_con * 0.14 * l1.clamp(-6.0, 4.0); + } + let clarity = fp.clar + l_clar; + if clarity != 0.0 + && let Some(blur) = &p.clarity_blur + { + let detail = (p.log_l.data[index] - blur.data[index]).clamp(-2.5, 2.5); + let midtone = (-(base / 3.2).powi(2)).exp(); + delta += clarity * 0.85 * detail * (0.35 + 0.65 * midtone); + } + let texture = fp.tex + l_tex; + let sharpen = l_sharp * 0.6 + fp.sharpen; + if (texture != 0.0 || sharpen != 0.0) + && let Some(blur) = &p.texture_blur + { + let detail = p.log_l.data[index] - blur.data[index]; + let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs()); + delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame; + if sharpen != 0.0 { + let mask = if fp.sharpen_mask > 0.0 { smooth(fp.sharpen_mask * 0.25, fp.sharpen_mask * 0.25 + 0.15, detail.abs()) } else { 1.0 }; + delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask; + } + } + if l_noise != 0.0 + && let Some(blur) = &p.texture_blur + { + let detail = p.log_l.data[index] - blur.data[index]; + delta -= l_noise.clamp(-1.0, 1.0) * 0.9 * detail * (1.0 - smooth(0.1, 0.5, detail.abs())); + } + delta +} + /// A colour needing more desaturation than this (scale < `GAMUT_WARN`) to fit is out of gamut for /// the gamut warnings (tolerates rounding at the gamut boundary). const GAMUT_WARN: f32 = 0.995; diff --git a/crates/pipeline/src/lut.rs b/crates/pipeline/src/lut.rs index b66c3444e..00f6ea185 100644 --- a/crates/pipeline/src/lut.rs +++ b/crates/pipeline/src/lut.rs @@ -24,7 +24,7 @@ impl Lut3d { let mut data = Vec::new(); let three = |rest: &str| -> Option<[f32; 3]> { let v: Vec = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect(); - (v.len() == 3).then(|| [v[0], v[1], v[2]]) + (v.len() == 3 && v.iter().all(|x| x.is_finite())).then(|| [v[0], v[1], v[2]]) }; for line in text.lines() { let l = line.trim(); @@ -45,6 +45,10 @@ impl Lut3d { } else if let Some(r) = l.strip_prefix("DOMAIN_MAX") { max = three(r).ok_or("bad DOMAIN_MAX")?; } else if l.starts_with(|c: char| c.is_ascii_alphabetic()) { + // a data line of `nan` / `inf` starts with a letter too: it is bad data, not a keyword + if l.split_whitespace().next().is_some_and(|w| w.parse::().is_ok()) { + return Err(format!("bad line `{l}`")); + } // other keywords (LUT_IN_VIDEO_RANGE…) are ignored } else { data.push(three(l).ok_or_else(|| format!("bad line `{l}`"))?); @@ -140,4 +144,21 @@ mod tests { assert!(Lut3d::parse_cube("LUT_1D_SIZE 4\n0 0 0").is_err()); assert!(Lut3d::parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err(), "too few entries"); } + + #[test] + fn non_finite_entries_rejected() { + let bad_line = |body: &str| { + let e = Lut3d::parse_cube(body).expect_err(body); + assert!(e.starts_with("bad line"), "{e}"); + }; + bad_line(&format!("LUT_3D_SIZE 2\n{}", "-inf inf nan\n".repeat(8))); + bad_line(&format!("LUT_3D_SIZE 2\n{}", "1e39 0 0\n".repeat(8))); + bad_line(&format!("LUT_3D_SIZE 2\n-inf 0 0\n{}", "0 0 0\n".repeat(7))); + bad_line(&format!("LUT_3D_SIZE 2\nnan 0 0\n{}", "0 0 0\n".repeat(8))); + bad_line(&format!("LUT_3D_SIZE 2\ninf 0 0\n{}", "0 0 0\n".repeat(8))); + let ok = cube(|c| c, 2); + assert!(Lut3d::parse_cube(&ok).is_ok()); + assert!(Lut3d::parse_cube(&format!("DOMAIN_MIN nan 0 0\n{ok}")).is_err()); + assert!(Lut3d::parse_cube(&format!("DOMAIN_MAX inf 1 1\n{ok}")).is_err()); + } } diff --git a/crates/preview/src/hash.rs b/crates/preview/src/hash.rs index e3ab51fe8..63650fd16 100644 --- a/crates/preview/src/hash.rs +++ b/crates/preview/src/hash.rs @@ -18,7 +18,8 @@ impl fmt::Display for Hash128 { impl Hash128 { pub fn parse(s: &str) -> Option { - (s.len() == 32).then(|| u128::from_str_radix(s, 16).ok().map(Hash128)).flatten() + // `from_str_radix` also accepts a leading '+', so check the digits explicitly. + (s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u128::from_str_radix(s, 16).ok().map(Hash128)).flatten() } } @@ -59,3 +60,16 @@ impl Hasher128 { pub fn hash_bytes(bytes: &[u8]) -> Hash128 { Hasher128::new().update(bytes).finish() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_rejects_signed_and_non_hex() { + assert_eq!(Hash128::parse(&format!("+{}", "1".repeat(31))), None); + assert_eq!(Hash128::parse(&format!("-{}", "1".repeat(31))), None); + let h = "0123456789abcdefABCDEF0123456789"; + assert_eq!(Hash128::parse(h).map(|x| x.to_string()), Some(h.to_lowercase())); + } +} diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index b8a3c599a..75f7674ac 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -15,7 +15,7 @@ mod bilinear; mod ppg; mod xtrans; -use crate::{Cfa, Normalized, Rgb32f}; +use crate::{Cfa, Normalized, Rect, Rgb32f}; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -114,6 +114,20 @@ pub fn demosaic(n: &Normalized, method: Method) -> Rgb32f { } } +/// [`demosaic`] with [`Method::Ahd`] of the window `r` alone, for Bayer mosaics (`None` for any other data, or a +/// window not inside the mosaic): the same pixels as demosaicing it all and cropping that to `r`, without +/// demosaicing the rest or copying the window out of it. +pub(crate) fn ahd_window(n: &Normalized, r: Rect) -> Option { + let (w, h) = (n.width, n.height); + let cfa = n.cfa.as_ref()?; + let inside = r.x.checked_add(r.width).is_some_and(|e| e <= w) && r.y.checked_add(r.height).is_some_and(|e| e <= h); + if n.cpp != 1 || !cfa.is_bayer() || w < 4 || h < 4 || n.data.len() != w * h || r.width == 0 || r.height == 0 || !inside { + return None; + } + let m = Mosaic { w, h, data: &n.data, cfa }; + Some(ahd::ahd_window(&m, r.x, r.y, r.width, r.height)) +} + /// Build a mosaic from an RGB image by sampling each pixel's CFA colour (for tests and synthetic data). pub fn mosaic_from_rgb(img: &Rgb32f, cfa: &Cfa) -> Normalized { let data = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).map(|(x, y)| img.get(x, y)[cfa.color_at(x, y) as usize]).collect(); @@ -192,6 +206,26 @@ mod tests { assert!(out.data.iter().all(|p| (p[0] - 0.3).abs() < 1e-5 && (p[1] - 0.55).abs() < 1e-5 && (p[2] - 0.8).abs() < 1e-5)); } + /// A window demosaiced alone is the same, bit for bit, as that part of the whole picture: at the edges and + /// corners (where the mosaic is reflected), in the middle, across tile seams, at every CFA phase. + #[test] + fn ahd_window_is_the_crop_of_the_whole() { + let img = edgy_scene(300, 270); + for pat in ["RGGB", "GBRG"] { + let n = mosaic_from_rgb(&img, &Cfa::bayer(pat).unwrap()); + let whole = demosaic(&n, Method::Ahd); + for r in + [Rect::new(0, 0, 300, 270), Rect::new(0, 0, 7, 5), Rect::new(1, 3, 298, 266), Rect::new(121, 130, 140, 9), Rect::new(293, 263, 7, 7)] + { + let part = ahd_window(&n, r).unwrap(); + let want = whole.clone().into_crop(r.x, r.y, r.width, r.height); + assert_eq!((part.width, part.height), (r.width, r.height)); + assert!(part.data.iter().zip(&want.data).all(|(a, b)| a.map(f32::to_bits) == b.map(f32::to_bits)), "{pat} {r:?}"); + } + assert!(ahd_window(&n, Rect::new(1, 0, 300, 270)).is_none(), "a window past the mosaic is refused"); + } + } + #[test] fn known_samples_are_preserved() { let img = edgy_scene(40, 32); diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index d9fc27d76..6afcd9251 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -21,19 +21,24 @@ const TILE: usize = 128; const MARGIN: usize = 6; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { - let (w, h) = (m.w, m.h); + ahd_window(m, 0, 0, m.w, m.h) +} + +/// The `w × h` pixels at (`x`, `y`) of [`ahd`]'s picture (inside the mosaic), demosaiced alone: every pixel comes +/// from the mosaic around it, so they are the same as when the whole picture is demosaiced and then cropped. +pub(crate) fn ahd_window(m: &Mosaic, x: usize, y: usize, w: usize, h: usize) -> Rgb32f { let mut out = Rgb32f::new(w, h); if w == 0 || h == 0 { return out; } // one row of tiles per task, its tiles in parallel too, written straight into the picture out.data.par_chunks_mut((w * TILE).max(1)).enumerate().for_each(|(ty, band)| { - let (y0, th) = (ty * TILE, band.len() / w.max(1)); + let (y0, th) = (y + ty * TILE, band.len() / w.max(1)); let tiles: Vec<(usize, usize, Vec<[f32; 3]>)> = (0..w.div_ceil(TILE)) .into_par_iter() .map(|tx| { let (x0, tw) = (tx * TILE, TILE.min(w - tx * TILE)); - (x0, tw, tile(m, x0, y0, tw, th)) + (x0, tw, tile(m, x + x0, y0, tw, th)) }) .collect(); for (x0, tw, px) in tiles { diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index fae6015b6..bdafb5865 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -284,48 +284,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { if let Some(v) = c.baseline_sharpness { ifd.set(t::BASELINE_SHARPNESS, rat_vec(&[v])); } - // the source's own profile look travels with its data (conversions, smart previews, merges) - let p = &c.profile; - let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| { - ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32])); - if table.srgb_value { - ifd.set(enc, Value::Long(vec![1])); - } - }; - let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect()); - match &p.hue_sat_map { - [Some(a), b] => { - table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a)); - if let Some(b) = - b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions)) - { - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b)); - } - } - [None, Some(b)] => { - table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); - ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b)); - } - [None, None] => {} - } - if let Some(l) = &p.look_table { - table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING); - ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l)); - } - if let Some(curve) = &p.tone_curve { - ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); - } - if let Some(map) = &p.gain_table_map { - // the raw IFD is IFD 0 here, where both tag versions are valid - let (version2, bytes) = map.to_bytes(opts.order); - ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes)); - } - for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] { - if !list.is_empty() { - ifd.set(tag, Value::Undefined(opcodes::write_list(list))); - } - } + write_profile_tags(&mut ifd, raw, opts.order); + write_opcode_tags(&mut ifd, raw); match (&raw.data, opts.compression) { (RawData::F32(v), DngCompression::Deflate { tile, half }) => { @@ -495,6 +455,54 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { Ok(TiffWriter::new(opts.order, false).write(&[ifd])?) } +fn write_profile_tags(ifd: &mut IfdBuilder, raw: &RawImage, order: ByteOrder) { + // The source's own profile look travels with its data (conversions, smart previews, merges). + let profile = &raw.color.profile; + let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| { + ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32])); + if table.srgb_value { + ifd.set(enc, Value::Long(vec![1])); + } + }; + let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect()); + match &profile.hue_sat_map { + [Some(first), second] => { + table_tags(ifd, first, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(first)); + if let Some(second) = second.as_ref().filter(|second| { + (second.hue_divisions, second.sat_divisions, second.val_divisions) == (first.hue_divisions, first.sat_divisions, first.val_divisions) + }) { + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(second)); + } + } + [None, Some(table)] => { + table_tags(ifd, table, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING); + ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(table)); + } + [None, None] => {} + } + if let Some(table) = &profile.look_table { + table_tags(ifd, table, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING); + ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(table)); + } + if let Some(curve) = &profile.tone_curve { + ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect())); + } + if let Some(map) = &profile.gain_table_map { + // The raw IFD is IFD 0 here, where both tag versions are valid. + let (version2, bytes) = map.to_bytes(order); + ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes)); + } +} + +fn write_opcode_tags(ifd: &mut IfdBuilder, raw: &RawImage) { + for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] { + if !list.is_empty() { + ifd.set(tag, Value::Undefined(opcodes::write_list(list))); + } + } +} + /// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23). fn illuminant_of(white: lightcraft_color::Xy) -> u16 { if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 } diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 209f8f4de..128f4eeea 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -12,7 +12,9 @@ //! Lossless (non-XYB) tiles are rendered in the codestream's own colour encoding, which is the identity, //! keeping the values in the raw IFD's sample space. Lossy (XYB) tiles are decoded by jxl-oxide to linear //! sRGB primaries; the codestream's declared encoding is then restored by hand: its primaries with a -//! plain 3×3 matrix, then its transfer curve. The samples of a lossy tile mean what they would in a +//! plain 3×3 matrix, then its transfer curve. A grey (single-plane) lossy tile is still coded as three +//! XYB channels; its declared grey encoding is restored as the luminance (CIE Y, D65 white) of the +//! linear sRGB output, then its transfer curve. The samples of a lossy tile mean what they would in a //! lossless one with the same header, so a declared transfer curve is part of the stored values, not a //! display hint. Lightroom Classic's lossy LinearRaw DNGs (Convert to DNG with lossy compression) store //! perceptually shaped samples, label them with the sRGB curve and linearise them with a cubic @@ -126,7 +128,7 @@ enum Output { /// Decide how to render a chunk from its header: whether it is XYB-coded, the declared colour /// encoding and the intensity target. Lossy data is only accepted when the declared encoding is an RGB -/// one with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG +/// one (three planes) or a grey one (one plane) with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG /// writers signal) and no HDR tone mapping would apply, otherwise the samples cannot be restored /// faithfully. fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, planes: usize) -> Result { @@ -134,13 +136,15 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane return Ok(Output::AsCoded); } let unsupported = |what: &str| RawError::Unsupported(format!("lossy JPEG XL tile with {what}")); - if planes != 3 { - return Err(unsupported("a single colour plane")); - } let ColourEncoding::Enum(e) = encoding else { return Err(unsupported("an ICC profile instead of a colour encoding")); }; - if e.colour_space != ColourSpace::Rgb { + // a single plane is declared grey, three are declared RGB (the header's grey flag is the colour space) + let grey = planes == 1; + if grey && e.colour_space != ColourSpace::Grey { + return Err(unsupported("a single colour plane that is not declared grey")); + } + if !grey && e.colour_space != ColourSpace::Rgb { return Err(unsupported("a declared encoding that is not RGB")); } let curve = match e.tf { @@ -167,6 +171,13 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane if intensity_target > 255.0 { return Err(unsupported("an intensity target above 255 nits")); } + if grey { + // a grey encoding has no primaries, only the white point: its single plane is the luminance (CIE Y) + // of the decoder's linear sRGB, which is the row of sRGB → XYZ that gives Y. The rows of the + // matrix are all that row; only the first is used for one plane. + let y = rgb_to_xyz(SRGB)[1].map(|v| v as f32); + return Ok(Output::FromXyb { matrix: Some([y; 3]), curve }); + } let primaries = match e.primaries { Primaries::Srgb => return Ok(Output::FromXyb { matrix: None, curve }), Primaries::Bt2100 => BT2100, @@ -337,6 +348,25 @@ mod tests { assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb { matrix: None, curve: Curve::Linear }); } + #[test] + fn lossy_grey_data_is_the_luminance_in_the_declared_curve() { + // Lightroom Classic's merged monochrome LinearRaw DNGs: one plane, declared grey with a linear + // (16-bit float) or an sRGB (16-bit integer) curve + let grey = |tf| { + let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Perceptual); + e.colour_space = ColourSpace::Grey; + e.tf = tf; + output_for(true, &ColourEncoding::Enum(e), 255.0, 1).unwrap() + }; + let Output::FromXyb { matrix: Some(m), curve: Curve::Linear } = grey(TransferFunction::Linear) else { panic!("grey, linear") }; + // the luminance of linear sRGB (IEC 61966-2-1: 0.2126, 0.7152, 0.0722); white stays white + for (v, e) in m[0].iter().zip([0.2126, 0.7152, 0.0722]) { + assert!((v - e).abs() < 1e-4, "{m:?}"); + } + assert!((m[0].iter().sum::() - 1.0).abs() < 1e-5, "{m:?}"); + assert!(matches!(grey(TransferFunction::Srgb), Output::FromXyb { curve: Curve::Srgb, .. })); + } + #[test] fn lossy_data_keeps_the_declared_transfer_curve() { // Lightroom Classic's lossy LinearRaw DNGs: Rec.2100 primaries, sRGB transfer curve @@ -463,10 +493,68 @@ mod tests { } } + /// A fixture tile re-declared grey: its colour encoding's colour space goes from RGB to Grey and, + /// as a grey encoding has no primaries, the 6-bit BT.2100 primaries field is dropped (ISO/IEC 18181-1 + /// ColourEncoding; the bits after it move up and the header's zero padding to the frame grows by 6). + /// The XYB frame data is untouched, so the codestream is one grey plane coded as three XYB channels + /// whose colour is the gradient's, like Lightroom Classic's merged monochrome DNGs. + fn as_grey(tile: &[u8]) -> Vec { + // bit offset of the colour space, read LSB first, and the frame's first byte in these fixture tiles + const AT: usize = 42; + const FRAME: usize = 8; + let mut bits: Vec = tile.iter().flat_map(|b| (0..8).map(move |i| (b >> i) & 1)).collect(); + // colour space Rgb (selector 0), white point D65 (selector 1), primaries Bt2100 (selector 2 + 4 bits of 7) + assert_eq!(bits[AT..AT + 10], [0, 0, 1, 0, 0, 1, 1, 1, 1, 0], "the fixture's colour encoding"); + bits[AT] = 1; // colour space Grey (selector 1) + bits.drain(AT + 4..AT + 10); + bits.splice(FRAME * 8 - 6..FRAME * 8 - 6, [0; 6]); + bits.as_chunks::<8>().0.iter().map(|b| b.iter().enumerate().fold(0, |a, (i, v)| a | v << i)).collect() + } + + #[test] + fn lossy_grey_tiles_decode_to_the_luminance_of_their_colour() { + let srgb_eotf = |v: f32| if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let y = rgb_to_xyz(BT2100)[1].map(|v| v as f32); + for (name, tile, curve) in [("linear", LINEAR_TILE, Curve::Linear), ("sRGB", SRGB_TILE, Curve::Srgb)] { + let grey = as_grey(tile); + // the decoder still renders three unequal channels: the restore must reduce them, not pick one + let mut img = JxlImage::builder().read(std::io::Cursor::new(&grey)).unwrap(); + assert!(img.image_header().metadata.grayscale(), "{name}: declared grey"); + img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative)); + let render = img.render_frame(0).unwrap(); + let fb = render.image_all_channels(); + assert_eq!(fb.channels(), 3, "{name}"); + assert!((fb.buf()[2] - fb.buf()[0]).abs() > 0.1, "{name}: the rendered channels differ"); + + let ChunkPx::U16(px) = decode(&grey, 16, 16, 16, 16, 1, 16, false).unwrap() else { panic!("{name}: integer samples") }; + assert_eq!(px.len(), 256, "{name}: one plane"); + let (mut sum, mut worst) = (0.0f32, 0.0f32); + for (i, got) in px.iter().enumerate() { + // the gradient's samples are coded in the tile's curve: back to linear for the luminance + let rgb = gradient(i % 16, i / 16).map(|v| if curve == Curve::Srgb { srgb_eotf(v / 65535.0) } else { v / 65535.0 }); + let want = curve.encode(y[0] * rgb[0] + y[1] * rgb[1] + y[2] * rgb[2]) * 65535.0; + let err = (f32::from(*got) - want).abs(); + sum += err; + worst = worst.max(err); + } + let mean = sum / px.len() as f32; + assert!(mean < 0.02 * 65535.0, "{name}: mean error {mean}"); + assert!(worst < 0.1 * 65535.0, "{name}: worst error {worst}"); + } + } + #[test] fn lossy_data_that_cannot_be_restored_is_unsupported() { let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_))); - assert!(bad(1, linear(Primaries::Srgb), 255.0), "grey"); + assert!(bad(1, linear(Primaries::Srgb), 255.0), "one plane declared RGB"); + let mut g = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); + g.colour_space = ColourSpace::Grey; + assert!(bad(1, ColourEncoding::Enum(g.clone()), 10_000.0), "grey with an HDR intensity target"); + g.tf = TransferFunction::Pq; + assert!(bad(1, ColourEncoding::Enum(g.clone()), 255.0), "grey with PQ"); + g.tf = TransferFunction::Linear; + g.white_point = WhitePoint::Dci; + assert!(bad(1, ColourEncoding::Enum(g), 255.0), "grey with a white point other than D65"); assert!(bad(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map"); let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); for tf in [TransferFunction::Pq, TransferFunction::Hlg, TransferFunction::Unknown, TransferFunction::Gamma { g: 0, inverted: true }] { diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 6626f10cb..17152e80f 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -866,6 +866,15 @@ impl RawImage { /// The result is camera RGB (not white balanced), white level = 1.0, not oriented. pub fn develop(&self, method: Method) -> Result { let n = self.normalized()?; + // with no `OpcodeList3` to apply to the whole picture first, AHD demosaics just the default crop (the same + // pixels, without demosaicing the margin and then copying the crop out of the full picture) + if self.opcodes.list3.is_empty() && method == Method::Ahd { + let c = self.develop_crop(n.width, n.height); + if let Some(rgb) = demosaic::ahd_window(&n, c) { + drop(n); + return Ok(self.stretched(rgb)); + } + } let rgb = demosaic(&n, method); drop(n); Ok(self.finish_demosaiced(rgb)) @@ -878,6 +887,11 @@ impl RawImage { let c = self.develop_crop(rgb.width, rgb.height); let rgb = if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { rgb } else { rgb.into_crop(c.x, c.y, c.width, c.height) }; + self.stretched(rgb) + } + + /// The cropped camera RGB with its rows stretched for non-square pixels. + fn stretched(&self, rgb: Rgb32f) -> Rgb32f { let h = stretched_height(rgb.height, self.pixel_aspect); if h == rgb.height { return rgb; diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index df85e2703..4d3e463b0 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -253,6 +253,9 @@ impl Rd<'_> { self.p += 8; Some(v) } + fn finite_f64(&mut self) -> Option { + self.f64().filter(|v| v.is_finite()) + } fn f32(&mut self) -> Option { let v = f32::from_be_bytes(self.d.get(self.p..self.p + 4)?.try_into().ok()?); self.p += 4; @@ -276,109 +279,126 @@ impl Rd<'_> { } fn parse_one(id: u32, flags: u32, params: &[u8]) -> Option { - let mut r = Rd { d: params, p: 0 }; - let f = |r: &mut Rd| r.f64().filter(|v| v.is_finite()); - Some(match id { + let mut reader = Rd { d: params, p: 0 }; + let parsed = match id { + 1 | 2 | 3 | 14 => parse_warp(id, flags, &mut reader)?, + 4 => Opcode::FixBadPixelsConstant { constant: reader.u32()?, bayer_phase: reader.u32()? }, + 5 => parse_bad_pixel_list(&mut reader)?, + 6 => Opcode::TrimBounds { top: reader.u32()?, left: reader.u32()?, bottom: reader.u32()?, right: reader.u32()? }, + 7..=13 => parse_area_opcode(id, &mut reader)?, + _ => Opcode::Unknown { id, flags, params: params.to_vec() }, + }; + Some(parsed) +} + +fn parse_warp(id: u32, flags: u32, reader: &mut Rd<'_>) -> Option { + match id { 1 => { - let n = r.u32()? as usize; - if n == 0 || n > 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]); - } - Opcode::WarpRectilinear { planes, center: [f(&mut r)?, f(&mut r)?] } + let planes = parse_planes(reader, 4, |r| { + Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?]) + })?; + Some(Opcode::WarpRectilinear { planes, center: [reader.finite_f64()?, reader.finite_f64()?] }) } 2 => { - let n = r.u32()? as usize; - if n == 0 || n > 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - planes.push([f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?]); - } - Opcode::WarpFisheye { planes, center: [f(&mut r)?, f(&mut r)?] } + let planes = parse_planes(reader, 4, |r| Some([r.finite_f64()?, r.finite_f64()?, r.finite_f64()?, r.finite_f64()?]))?; + Some(Opcode::WarpFisheye { planes, center: [reader.finite_f64()?, reader.finite_f64()?] }) } - 14 => { - let n = r.u32()? as usize; - // N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else - if n == 0 || n > WR2_MAX_PLANES || params.len() != 4 + n * WR2_LEN * 8 + 16 + 4 { - return None; - } - let mut planes = Vec::with_capacity(n); - for _ in 0..n { - let mut k = [0.0; WR2_LEN]; - for v in k.iter_mut() { - *v = f(&mut r).filter(|v| v.abs() <= WR2_MAX_COEF)?; - } - // the spec requires 0 <= min_valid_radius < max_valid_radius <= 1 - if !(k[17] >= 0.0 && k[17] < k[18] && k[18] <= 1.0) { - return None; - } - planes.push(k); - } - let center = [f(&mut r).filter(|v| v.abs() <= 4.0)?, f(&mut r).filter(|v| v.abs() <= 4.0)?]; - Opcode::WarpRectilinear2 { planes, center, reciprocal: r.u32()? != 0, optional: flags & 1 != 0 } + 3 => Some(Opcode::FixVignetteRadial { + k: [reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?, reader.finite_f64()?], + center: [reader.finite_f64()?, reader.finite_f64()?], + }), + 14 => parse_warp_rectilinear2(flags, reader), + _ => None, + } +} + +fn parse_planes(reader: &mut Rd<'_>, max: usize, mut parse: impl FnMut(&mut Rd<'_>) -> Option<[f64; N]>) -> Option> { + let count = reader.u32()? as usize; + if count == 0 || count > max { + return None; + } + (0..count).map(|_| parse(reader)).collect() +} + +fn parse_warp_rectilinear2(flags: u32, reader: &mut Rd<'_>) -> Option { + let count = reader.u32()? as usize; + // N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else. + if count == 0 || count > WR2_MAX_PLANES || reader.remaining() != count * WR2_LEN * 8 + 16 + 4 { + return None; + } + let mut planes = Vec::with_capacity(count); + for _ in 0..count { + let mut coefficients = [0.0; WR2_LEN]; + for value in &mut coefficients { + *value = reader.finite_f64().filter(|value| value.abs() <= WR2_MAX_COEF)?; } - 3 => Opcode::FixVignetteRadial { k: [f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?], center: [f(&mut r)?, f(&mut r)?] }, - 4 => Opcode::FixBadPixelsConstant { constant: r.u32()?, bayer_phase: r.u32()? }, - 5 => { - let bayer_phase = r.u32()?; - let (np, nr) = (r.u32()? as usize, r.u32()? as usize); - if np.saturating_mul(8).saturating_add(nr.saturating_mul(16)) > r.remaining() { - return None; - } - let points = (0..np).map(|_| Some((r.u32()?, r.u32()?))).collect::>()?; - let rects = (0..nr).map(|_| Some([r.u32()?, r.u32()?, r.u32()?, r.u32()?])).collect::>()?; - Opcode::FixBadPixelsList { bayer_phase, points, rects } + if !(coefficients[17] >= 0.0 && coefficients[17] < coefficients[18] && coefficients[18] <= 1.0) { + return None; } - 6 => Opcode::TrimBounds { top: r.u32()?, left: r.u32()?, bottom: r.u32()?, right: r.u32()? }, + planes.push(coefficients); + } + let center = [reader.finite_f64().filter(|value| value.abs() <= 4.0)?, reader.finite_f64().filter(|value| value.abs() <= 4.0)?]; + Some(Opcode::WarpRectilinear2 { planes, center, reciprocal: reader.u32()? != 0, optional: flags & 1 != 0 }) +} + +fn parse_bad_pixel_list(reader: &mut Rd<'_>) -> Option { + let bayer_phase = reader.u32()?; + let (point_count, rect_count) = (reader.u32()? as usize, reader.u32()? as usize); + if point_count.saturating_mul(8).saturating_add(rect_count.saturating_mul(16)) > reader.remaining() { + return None; + } + let points = (0..point_count).map(|_| Some((reader.u32()?, reader.u32()?))).collect::>()?; + let rects = (0..rect_count).map(|_| Some([reader.u32()?, reader.u32()?, reader.u32()?, reader.u32()?])).collect::>()?; + Some(Opcode::FixBadPixelsList { bayer_phase, points, rects }) +} + +fn parse_area_opcode(id: u32, reader: &mut Rd<'_>) -> Option { + let area = reader.area()?; + match id { 7 => { - let area = r.area()?; - let n = r.u32()? as usize; - if n == 0 || n > 65536 || n * 2 > r.remaining() { + let count = reader.u32()? as usize; + if count == 0 || count > 65536 || count * 2 > reader.remaining() { return None; } - Opcode::MapTable { area, table: (0..n).map(|_| r.u16()).collect::>()? } + Some(Opcode::MapTable { area, table: (0..count).map(|_| reader.u16()).collect::>()? }) } 8 => { - let area = r.area()?; - let deg = r.u32()? as usize; - if deg > 8 { + let degree = reader.u32()? as usize; + if degree > 8 { return None; } - Opcode::MapPolynomial { area, coefficients: (0..=deg).map(|_| f(&mut r)).collect::>()? } + Some(Opcode::MapPolynomial { area, coefficients: (0..=degree).map(|_| reader.finite_f64()).collect::>()? }) } - 9 => { - let area = r.area()?; - let (pv, ph) = (r.u32()?, r.u32()?); - let spacing = [f(&mut r)?, f(&mut r)?]; - let origin = [f(&mut r)?, f(&mut r)?]; - let mp = r.u32()?; - let n = (pv as usize).checked_mul(ph as usize)?.checked_mul(mp as usize)?; - if n == 0 || n * 4 > r.remaining() { - return None; - } - let gains = (0..n).map(|_| r.f32()).collect::>()?; - Opcode::GainMap { area, points_v: pv, points_h: ph, spacing, origin, map_planes: mp, gains } - } - 10..=13 => { - let area = r.area()?; - let n = r.u32()? as usize; - if n * 4 > r.remaining() { - return None; - } - let v: Vec = (0..n).map(|_| r.f32()).collect::>()?; - match id { - 10 => Opcode::DeltaPerRow { area, deltas: v }, - 11 => Opcode::DeltaPerColumn { area, deltas: v }, - 12 => Opcode::ScalePerRow { area, scales: v }, - _ => Opcode::ScalePerColumn { area, scales: v }, - } - } - _ => Opcode::Unknown { id, flags, params: params.to_vec() }, + 9 => parse_gain_map(area, reader), + 10..=13 => parse_delta_scale(id, area, reader), + _ => None, + } +} + +fn parse_gain_map(area: Area, reader: &mut Rd<'_>) -> Option { + let (points_v, points_h) = (reader.u32()?, reader.u32()?); + let spacing = [reader.finite_f64()?, reader.finite_f64()?]; + let origin = [reader.finite_f64()?, reader.finite_f64()?]; + let map_planes = reader.u32()?; + let count = (points_v as usize).checked_mul(points_h as usize)?.checked_mul(map_planes as usize)?; + if count == 0 || count > reader.remaining() / 4 { + return None; + } + let gains = (0..count).map(|_| reader.f32()).collect::>()?; + Some(Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains }) +} + +fn parse_delta_scale(id: u32, area: Area, reader: &mut Rd<'_>) -> Option { + let count = reader.u32()? as usize; + if count > reader.remaining() / 4 { + return None; + } + let values: Vec = (0..count).map(|_| reader.f32()).collect::>()?; + Some(match id { + 10 => Opcode::DeltaPerRow { area, deltas: values }, + 11 => Opcode::DeltaPerColumn { area, deltas: values }, + 12 => Opcode::ScalePerRow { area, scales: values }, + _ => Opcode::ScalePerColumn { area, scales: values }, }) } @@ -508,19 +528,29 @@ fn area_iter(a: &Area, w: usize, h: usize, cpp: usize) -> impl Iterator f32 { - let (pv, ph) = (points_v as usize, points_h as usize); - let mp = map_planes as usize; - let p = plane.min(mp - 1); - let fy = if spacing[0] > 0.0 { ((rv - origin[0]) / spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 }; - let fx = if spacing[1] > 0.0 { ((rh - origin[1]) / spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 }; - let (y0, x0) = (fy.floor() as usize, fx.floor() as usize); - let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1)); - let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32); - let g = |y: usize, x: usize| gains[(y * ph + x) * mp + p]; - let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx; - let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx; - top + (bot - top) * ty +struct GainMapGrid<'a> { + points: [usize; 2], + spacing: [f64; 2], + origin: [f64; 2], + planes: usize, + gains: &'a [f32], +} + +impl GainMapGrid<'_> { + fn sample_at(&self, position: [f64; 2], plane: usize) -> f32 { + let [pv, ph] = self.points; + let mp = self.planes; + let p = plane.min(mp - 1); + let fy = if self.spacing[0] > 0.0 { ((position[0] - self.origin[0]) / self.spacing[0]).clamp(0.0, (pv - 1) as f64) } else { 0.0 }; + let fx = if self.spacing[1] > 0.0 { ((position[1] - self.origin[1]) / self.spacing[1]).clamp(0.0, (ph - 1) as f64) } else { 0.0 }; + let (y0, x0) = (fy.floor() as usize, fx.floor() as usize); + let (y1, x1) = ((y0 + 1).min(pv - 1), (x0 + 1).min(ph - 1)); + let (ty, tx) = ((fy - y0 as f64) as f32, (fx - x0 as f64) as f32); + let g = |y: usize, x: usize| self.gains[(y * ph + x) * mp + p]; + let top = g(y0, x0) + (g(y0, x1) - g(y0, x0)) * tx; + let bot = g(y1, x0) + (g(y1, x1) - g(y1, x0)) * tx; + top + (bot - top) * ty + } } /// Radius normaliser: distance from the centre to the farthest image corner. @@ -556,10 +586,17 @@ pub fn apply_list(list: &[Opcode], buf: &mut [f32], w: usize, h: usize, cpp: usi } } Opcode::GainMap { area, points_v, points_h, spacing, origin, map_planes, gains } => { + let grid = GainMapGrid { + points: [*points_v as usize, *points_h as usize], + spacing: *spacing, + origin: *origin, + planes: *map_planes as usize, + gains, + }; for (x, y, p) in area_iter(area, w, h, cpp) { let rv = y as f64 / h as f64; let rh = x as f64 / w as f64; - let g = gain_at(*points_v, *points_h, *spacing, *origin, *map_planes, gains, rv, rh, p - area.plane as usize); + let g = grid.sample_at([rv, rh], p - area.plane as usize); buf[(y * w + x) * cpp + p] *= g; } } diff --git a/crates/raw/src/opcodes/tests.rs b/crates/raw/src/opcodes/tests.rs index e63a0bf56..d1be999e9 100644 --- a/crates/raw/src/opcodes/tests.rs +++ b/crates/raw/src/opcodes/tests.rs @@ -1,5 +1,19 @@ use super::*; +#[test] +fn reject_opcode_counts_whose_bytes_exceed_the_remaining_data() { + let mut gain_map = Vec::new(); + for value in [u32::MAX, 1] { + gain_map.extend_from_slice(&value.to_be_bytes()); + } + gain_map.extend(be(&[1.0, 1.0, 0.0, 0.0])); + gain_map.extend_from_slice(&1u32.to_be_bytes()); + assert!(parse_gain_map(Area::default(), &mut Rd { d: &gain_map, p: 0 }).is_none()); + + let delta_count = u32::MAX.to_be_bytes(); + assert!(parse_delta_scale(10, Area::default(), &mut Rd { d: &delta_count, p: 0 }).is_none()); +} + pub(crate) struct ListWriter(pub Vec, pub u32); impl ListWriter { diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 57878b881..b6a1971ce 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -1,6 +1,6 @@ //! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless/lossy JPEG, Deflate, JPEG XL), in parallel. -use crate::unpack::*; +use crate::unpack::{f16_to_f32, f24_to_f32, inflate, read_u16s, undo_diff_u16, undo_float_predictor, unpack_lsb, unpack_msb, unpack_words32_msb}; use crate::{MAX_SAMPLES, RawData, RawError, Result, ljpeg}; use lightcraft_tiff::image::{Chunk, ImageInfo, chunk_bytes}; use lightcraft_tiff::{ByteOrder, tags::compression as comp}; diff --git a/crates/raw/src/vendor/erf.rs b/crates/raw/src/vendor/erf.rs index b9861ef38..7f88dae2a 100644 --- a/crates/raw/src/vendor/erf.rs +++ b/crates/raw/src/vendor/erf.rs @@ -95,8 +95,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { _ => Rect::new(0, 0, w, h), }; // the white balance is measured from the samples, so the header probe reads them too + // the strip length is the tag value, so a short file must fail before the sample buffer is sized from it let chunks = info.chunks(bytes.len() as u64); - let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Erf strip outside file".into()))?; + let src = chunks + .first() + .and_then(|c| chunk_bytes(bytes, c).filter(|s| s.len() as u64 == c.len)) + .ok_or_else(|| RawError::Corrupt("Erf strip outside file".into()))?; let mut d = vec![0u16; n]; unpack(src, &mut d); let white = super::white_from_data(&d, 12); @@ -196,4 +200,35 @@ mod tests { } } } + + /// Rewrite the raw IFD's height, rows per strip and strip length in place (the strip data stays as written). + fn declare(f: &mut [u8], rows: u32, strip_len: u32) { + let tiff = Tiff::parse(f).unwrap(); + let raw = &tiff.ifds[0].sub_ifds[0]; + let spots: Vec<_> = [(t::IMAGE_LENGTH, rows), (t::ROWS_PER_STRIP, rows), (t::STRIP_BYTE_COUNTS, strip_len)] + .into_iter() + .map(|(tag, v)| { + let e = raw.entries.iter().find(|e| e.tag == tag).unwrap(); + (e.offset as usize, matches!(e.value, Value::Short(_)), v) + }) + .collect(); + for (at, short, v) in spots { + if short { + f[at..at + 2].copy_from_slice(&u16::try_from(v).unwrap().to_be_bytes()); + } else { + f[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + } + } + + #[test] + fn a_strip_longer_than_the_file_is_an_error() { + // #2119: the declared strip length matched the size, so a few bytes of data were decoded as a full frame + let px: Vec = (0..40).map(|i| (i * 13 % 4096) as u16).collect(); + let mut f = sample(20, 2, &px); + declare(&mut f, 2000, 32 * 2000); + assert_eq!(crate::probe(&f), Some(RawFormat::Erf)); + assert!(matches!(crate::probe_info(&f), Err(RawError::Corrupt(_)))); + assert!(matches!(crate::decode(&f), Err(RawError::Corrupt(_)))); + } } diff --git a/crates/raw/src/vendor/mef.rs b/crates/raw/src/vendor/mef.rs index d5c5d83b4..d1c9a7754 100644 --- a/crates/raw/src/vendor/mef.rs +++ b/crates/raw/src/vendor/mef.rs @@ -87,8 +87,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { None => Rect::new(0, 0, w, h), }; // the white balance is measured from the samples, so the header probe reads them too + // the strip length is the tag value, so a short file must fail before the sample buffer is sized from it let chunks = info.chunks(bytes.len() as u64); - let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; + let src = chunks + .first() + .and_then(|c| chunk_bytes(bytes, c).filter(|s| s.len() as u64 == c.len)) + .ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; // 12-bit MSB packing takes 3 bytes per 2 samples; a strip that cannot hold the declared // samples is a truncated file, not a picture — unpacking it would run past the real bytes // through every output sample (a short strip asks for the whole allocation and more). @@ -241,4 +245,35 @@ mod tests { } } } + + /// Rewrite the raw IFD's height, rows per strip and strip length in place (the strip data stays as written). + fn declare(f: &mut [u8], rows: u32, strip_len: u32) { + let tiff = Tiff::parse(f).unwrap(); + let raw = &tiff.ifds[0].sub_ifds[0]; + let spots: Vec<_> = [(t::IMAGE_LENGTH, rows), (t::ROWS_PER_STRIP, rows), (t::STRIP_BYTE_COUNTS, strip_len)] + .into_iter() + .map(|(tag, v)| { + let e = raw.entries.iter().find(|e| e.tag == tag).unwrap(); + (e.offset as usize, matches!(e.value, Value::Short(_)), v) + }) + .collect(); + for (at, short, v) in spots { + if short { + f[at..at + 2].copy_from_slice(&u16::try_from(v).unwrap().to_be_bytes()); + } else { + f[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + } + } + + #[test] + fn a_strip_longer_than_the_file_is_an_error() { + // #2119: the declared strip length matched the size, so a few bytes of data were decoded as a full frame + let px: Vec = (0..40).map(|i| (i * 13 % 4096) as u16).collect(); + let mut f = sample(20, 2, &px); + declare(&mut f, 2000, 30 * 2000); + assert_eq!(crate::probe(&f), Some(RawFormat::Mef)); + assert!(matches!(crate::probe_info(&f), Err(RawError::Corrupt(_)))); + assert!(matches!(crate::decode(&f), Err(RawError::Corrupt(_)))); + } } diff --git a/crates/raw/tests/it/crx_properties.rs b/crates/raw/tests/it/crx_properties.rs index 804970de4..ec61aa3b8 100644 --- a/crates/raw/tests/it/crx_properties.rs +++ b/crates/raw/tests/it/crx_properties.rs @@ -632,20 +632,23 @@ fn lossless_file((width, height): (usize, usize), values: &[i32]) -> Fixture { /// Constant-coefficient planes for one tile: LL3 flat at `ll3`, band `mid_band.0` flat at `mid_band.1`, /// everything else zero. Encoded at the given level count with the given quant/gain/base parameters. -fn constant_band_planes( - plane_w: usize, - plane_h: usize, +struct ConstantBandPlaneConfig<'a> { + plane_size: (usize, usize), levels: u8, ll3: i32, mid_band: Option<(usize, i32)>, quant: u8, gains: [u32; 10], bases: [u32; 10], - qp: Option<&[i32]>, + qp: Option<&'a [i32]>, qp_padding: usize, - before: bool, - after: bool, -) -> (usize, Vec) { + boundary_bands: (bool, bool), +} + +fn constant_band_planes(config: ConstantBandPlaneConfig<'_>) -> (usize, Vec) { + let ConstantBandPlaneConfig { plane_size: (plane_w, plane_h), levels, ll3, mid_band, quant, gains, bases, qp, qp_padding, boundary_bands } = + config; + let (before, after) = boundary_bands; let planes: [Vec>; 4] = if levels == 0 { let mut encoded: [Vec>; 4] = Default::default(); for bands in encoded.iter_mut() { @@ -681,7 +684,18 @@ fn constant_band_planes( /// A 16×16 three-level C-RAW file built from constant bands at the given uniform quant value. fn constant_bands_file(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Fixture { - let (header_size, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (8, 8), + levels: 3, + ll3, + mid_band, + quant, + gains: [0; 10], + bases: [0; 10], + qp: None, + qp_padding: 0, + boundary_bands: (false, false), + }); let coding = cmp1(0x100, 16, 16, 16, 16, 3, header_size as u32); Fixture { file: wrap(&coding, 16, 16, &sample, None), expected: Vec::new() } } @@ -697,7 +711,18 @@ fn v200_mapped_file( bases: [u32; 10], ) -> Fixture { assert_eq!(map.len(), (width / 16) * (height / 4)); - let (header_size, sample) = constant_band_planes(width / 2, height / 2, 3, ll3, mid_band, 4, gains, bases, Some(map), 3, false, false); + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (width / 2, height / 2), + levels: 3, + ll3, + mid_band, + quant: 4, + gains, + bases, + qp: Some(map), + qp_padding: 3, + boundary_bands: (false, false), + }); let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected: Vec::new() } } @@ -811,20 +836,18 @@ proptest! { } *b = if i < 4 { base + 1 } else { base }; } - let (header_size, sample) = constant_band_planes( - width / 2, - height / 2, - 3, + let (header_size, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (width / 2, height / 2), + levels: 3, ll3, - Some((4, mid)), - 4, + mid_band: Some((4, mid)), + quant: 4, gains, bases, - Some(&qp_values[..map_w * map_h]), - 3, - false, - false, - ); + qp: Some(&qp_values[..map_w * map_h]), + qp_padding: 3, + boundary_bands: (false, false), + }); let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); let file = wrap(&coding, width as u32, height as u32, &sample, None); let image = decode(&file).unwrap(); @@ -879,7 +902,18 @@ fn lossless_sample(value: i32) -> Vec { /// Raw sample bytes of a constant-band 16×16 C-RAW fixture. fn constant_bands_sample(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Vec { - let (_, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + let (_, sample) = constant_band_planes(ConstantBandPlaneConfig { + plane_size: (8, 8), + levels: 3, + ll3, + mid_band, + quant, + gains: [0; 10], + bases: [0; 10], + qp: None, + qp_padding: 0, + boundary_bands: (false, false), + }); sample } diff --git a/crates/scenes/src/paint.rs b/crates/scenes/src/paint.rs index 4f3c4560b..32ca0f053 100644 --- a/crates/scenes/src/paint.rs +++ b/crates/scenes/src/paint.rs @@ -179,7 +179,14 @@ fn pine(p: &P, x: f32, v: f32, ground: f32, height: f32, density: f32) -> Option best } -fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, tint: C, sun: (f32, f32), sun_col: C) -> C { +struct WaterLight { + tint: C, + sun: (f32, f32), + sun_col: C, +} + +fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, light: WaterLight) -> C { + let WaterLight { tint, sun, sun_col } = light; let d = v - wl; let rip = fbm(x * 9.0, d * 60.0 / (d + 0.05), p.seed ^ 0xaa, 4) * (1.0 - calm); let rv = (wl - d * 1.0 + rip * (0.01 + 0.06 * (1.0 - calm)) * (d * 10.0 + 0.2)).max(0.0); @@ -340,7 +347,7 @@ fn alpine(p: &P, x: f32, v: f32, blue: bool) -> C { }; if v > wl { let tint = if blue { hex(0x1a2440) } else { hex(0x20303a) }; - return water(p, x, v, wl, 0.75, &above, tint, sun, sun_col); + return water(p, x, v, wl, 0.75, &above, WaterLight { tint, sun, sun_col }); } above(x, v) } @@ -419,7 +426,7 @@ fn ocean(p: &P, x: f32, v: f32) -> C { if v < hz + head * 0.3 { return hex(0x120c14); } - return water(p, x, v, hz, 0.35, &above, hex(0x1a2040), sun, sun_col); + return water(p, x, v, hz, 0.35, &above, WaterLight { tint: hex(0x1a2040), sun, sun_col }); } let _ = head; above(x, v) @@ -465,7 +472,7 @@ fn aurora(p: &P, x: f32, v: f32) -> C { add(sky(p, &sk, x, v), aur(x, v)) }; if v > wl { - return water(p, x, v, wl, 0.9, &above, hex(0x020408), (-5.0, 5.0), [0.0; 3]); + return water(p, x, v, wl, 0.9, &above, WaterLight { tint: hex(0x020408), sun: (-5.0, 5.0), sun_col: [0.0; 3] }); } above(x, v) } diff --git a/crates/sysmem/Cargo.toml b/crates/sysmem/Cargo.toml index 422d8c711..a7f0492c6 100644 --- a/crates/sysmem/Cargo.toml +++ b/crates/sysmem/Cargo.toml @@ -12,6 +12,9 @@ description = "LightCraft sysmem (L0): returns freed allocator memory to the OS. unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 306804df0..f740aaed9 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -6,6 +6,7 @@ "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Фильтр скрывает фото в этом представлении: измените его или сбросьте (Вид → Сбросить фильтры)", "A one-time download of about": "Однократная загрузка около", "A pen's pressure sets the size and flow of the brush": "Нажим пера задаёт размер и поток кисти", + "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Слайд показывается заданное время, включая его появление. Нулевое затухание означает резкую смену.", "About LightCraft": "О программе LightCraft", "Activity": "Активность", "Actual size": "Реальный размер", @@ -13,9 +14,13 @@ "Add Catchlight": "Добавить блик в глазах", "Add Folder to Local": "Добавить папку в «Локальное»", "Add Point Color Sample": "Добавить точечный образец цвета", + "Add Print Cell": "Добавить ячейку печати", "Add Red Eye Correction": "Добавить коррекцию красных глаз", "Add Remove Spot": "Добавить удаление пятна", "Add Selected Photos": "Добавить выбранные фото", + "Add a Face Recognition Model": "Добавить модель распознавания лиц", + "Add a cell": "Добавить ячейку", + "Add a model file…": "Добавить файл модели…", "Add a rule (⌥-click: a group)": "Добавить условие (⌥+щелчок: группу)", "Add in place": "Добавить на месте", "Add in place: the library references the files where they are; nothing is copied or moved.": "Добавить на месте: библиотека ссылается на файлы там, где они лежат; ничего не копируется и не перемещается.", @@ -26,7 +31,193 @@ "Add to Album": "Добавить в альбом", "Add to Favorites": "Добавить в избранное", "Add to My Photos": "Добавить в «Мои фото»", + "Already installed. Installing again is harmless.": "Уже установлено. Повторная установка ничего не испортит.", + "Also the masks of the same name on the other selected photos": "Также маски с тем же именем на других выбранных фото", + "Apple Photos Imports": "Импорт из Apple Photos", + "Apply Mask Effect": "Применить эффект маски", + "Apply Print Template": "Применить шаблон печати", + "Apply Slideshow Template": "Применить шаблон слайд-шоу", + "Apply to All Masks": "Применить ко всем маскам", + "Arrange Print Cell": "Упорядочить ячейку печати", + "Auto Layout": "Автомакет", + "Background Tasks": "Фоновые задачи", "Can't be moved here": "Сюда переместить нельзя", + "Cancel Background Task": "Отменить фоновую задачу", + "Choose Layout Image…": "Выбрать изображение макета…", + "Choose Monitor Profile": "Выбрать профиль монитора", + "Choose Profile…": "Выбрать профиль…", + "Choose a group": "Выберите группу", + "Choose at least one setting to include": "Выберите хотя бы один параметр", + "Clear Print Layout": "Очистить макет печати", + "Click a cell to move, resize or arrange it": "Щёлкните ячейку, чтобы переместить её, изменить размер или порядок", + "Click to name this face": "Щёлкните, чтобы назвать это лицо", + "Color Overlay": "Цветное наложение", + "Color Overlay on B&W": "Цветное наложение на Ч/Б", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Цветовое пространство: Rec. 2020 HLG (HDR AVIF)", + "Color wheel": "Цветовой круг", + "Combine with the existing person": "Объединить с существующим человеком", + "Contact Sheet PDF…": "Контактный лист PDF…", + "Copy Settings…": "Копировать настройки…", + "Create Keyword": "Создать ключевое слово", + "Curve channel": "Канал кривой", + "Cycle Flag": "Переключить флаг", + "Cycle Tool Overlay": "Переключить наложение инструмента", + "Decrease Flag Status": "Понизить статус флага", + "Decrease Grid Thumbnail Size": "Уменьшить миниатюры сетки", + "Delete Cell": "Удалить ячейку", + "Delete Mask Effect": "Удалить эффект маски", + "Delete Print Cell": "Удалить ячейку печати", + "Delete Print Template": "Удалить шаблон печати", + "Delete Slideshow Template": "Удалить шаблон слайд-шоу", + "Delete…": "Удалить…", + "Display": "Дисплей", + "Display Profile": "Профиль дисплея", + "Display profile not used": "Профиль дисплея не используется", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Перетащите, чтобы задать тон и насыщенность. Удерживайте Shift, чтобы сохранить тон, Cmd/Ctrl, чтобы сохранить насыщенность, Alt/Option для точной настройки. Двойной щелчок сбрасывает.", + "Duplicate Mask Effect": "Дублировать эффект маски", + "Edit In": "Редактировать в", + "Edit Keyword": "Изменить ключевое слово", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Правка в HDR: света выше белого SDR, для экспорта и дисплеев HDR", + "Edit in Other Editor": "Редактировать в другом редакторе", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "«Редактировать в другом редакторе» (⌥⌘E) открывает копию в этом приложении (на macOS это имя приложения, в других системах путь к программе).", + "End Slideshow": "Завершить слайд-шоу", + "Enter to confirm, Esc to cancel": "Enter подтверждает, Esc отменяет", + "Export Contact Sheet PDF": "Экспорт контактного листа PDF", + "Export Effect Presets": "Экспортировать пресеты эффектов", + "Export Effect Presets…": "Экспортировать пресеты эффектов…", + "Export Mask Effects": "Экспортировать эффекты масок", + "External Editing": "Внешнее редактирование", + "Face Models on the GPU": "Модели лиц на ГП", + "Face models (ONNX)": "Модели лиц (ONNX)", + "Fade": "Затухание", + "Fade duration": "Длительность затухания", + "Favorite": "Избранное", + "Favorite Mask Effect": "Избранный эффект маски", + "Filters": "Фильтры", + "Find Blemishes": "Найти дефекты", + "Found automatically": "Найдено автоматически", + "Group (optional)": "Группа (необязательно)", + "HDR Headroom": "Запас HDR", + "Hide this wheel's effect": "Скрыть эффект этого круга", + "Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Выделены ползунки, которые меняет эта маска. Остальные ползунки при применении пресета остаются как есть.", + "ICC Profiles": "Профили ICC", + "Image on Black": "Изображение на чёрном", + "Image on White": "Изображение на белом", + "Import Mask Effects": "Импортировать эффекты масок", + "Import Slideshow Template": "Импорт шаблона слайд-шоу", + "Include Amount": "Включить количество", + "Include brush size, feather and flow": "Включить размер, растушёвку и поток кисти", + "Include selected photos": "Включить выбранные фото", + "Increase Flag Status": "Повысить статус флага", + "Increase Grid Thumbnail Size": "Увеличить миниатюры сетки", + "Keyword Info": "Сведения о ключевом слове", + "LUT-based": "На основе LUT", + "Layout Image Matte": "Подложка изображения макета", + "Layout Image Opacity": "Непрозрачность изображения макета", + "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.": "Слева: стандартный диапазон (SDR) до белого SDR. Справа: диапазон HDR, по одной полосе на каждую ступень выше белого SDR (оранжевая линия показывает запас HDR). Когда включена визуализация диапазона HDR, полосы окрашиваются в её цвета.", + "LightCraft Effect Presets": "Пресеты эффектов LightCraft", + "Manage Effect Presets…": "Управление пресетами эффектов…", + "Manage Presets…": "Управление пресетами…", + "Managed by Printer (Adobe RGB)": "Управляется принтером (Adobe RGB)", + "Managed by Printer (sRGB)": "Управляется принтером (sRGB)", + "Mask": "Маска", + "Mask Effect Presets": "Пресеты эффектов масок", + "Masks keep their sliders. Export presets first to keep a copy.": "Маски сохранят свои ползунки. Чтобы сохранить копию, сначала экспортируйте пресеты.", + "Monitor profile": "Профиль монитора", + "Move Keyword": "Переместить ключевое слово", + "Move Mask Effect to Group": "Переместить эффект маски в группу", + "Move Print Cell": "Переместить ячейку печати", + "Move Print Cell to Page": "Переместить ячейку печати на страницу", + "Move to Group…": "Переместить в группу…", + "Name the preset": "Назовите пресет", + "New Print Page": "Новая страница печати", + "New group — Return": "Новая группа: Return", + "New spot (Return adds it here)": "Новая точка (Return добавляет её сюда)", + "Next History Step": "Следующий шаг истории", + "Next Slide": "Следующий слайд", + "Next page": "Следующая страница", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) использует сжатие intoPIX TicoRAW. У LightCraft пока нет лицензии на этот кодек, поэтому доступен только встроенный JPEG-просмотр камеры, а не необработанные данные сенсора. Чтобы редактировать RAW-данные, снимайте в NEF со сжатием без потерь или преобразуйте имеющиеся файлы HE в DNG лицензированным конвертером.", + "No Matte": "Без подложки", + "No change": "Без изменений", + "No effect matches": "Подходящих эффектов нет", + "No effect presets yet": "Пресетов эффектов пока нет", + "None (the display is treated as sRGB)": "Нет (дисплей считается sRGB)", + "One photo fills each page inside the margins.": "Одно фото заполняет каждую страницу в пределах полей.", + "Open Original in Other Editor (RAW, no copy)": "Открыть оригинал в другом редакторе (RAW, без копии)", + "Open Settings": "Открыть настройки", + "Other editor": "Другой редактор", + "Overlay color": "Цвет наложения", + "Page": "Страница", + "Pause / Resume Slideshow": "Пауза / продолжение слайд-шоу", + "Photo Above": "Фото выше", + "Photo Below": "Фото ниже", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Фото › Редактировать в › Редактировать в PhotoCraft (⇧⌘E или щелчок правой кнопкой по фото) создаёт копию с вашими правками, объединяет её в стопку с оригиналом и открывает в PhotoCraft. Сохраните там, и копия обновится здесь. Если копия в формате PSD, PhotoCraft сохраняет прямо в неё. Оставьте расположение пустым, если не пользуетесь переносной копией.", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Фото показываются через ICC-профиль монитора, поэтому цвета верны на дисплеях с широким охватом и на откалиброванных дисплеях, а режим «Детализация» использует весь цветовой охват дисплея. Гистограмму, кэши просмотров и экспорт это не затрагивает.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Фото останутся во «Всех фото». При удалении папки удаляются и её альбомы. Это действие можно отменить.", + "Previous History Step": "Предыдущий шаг истории", + "Previous Slide": "Предыдущий слайд", + "Previous page": "Предыдущая страница", + "Primaries (x, y)": "Основные цвета (x, y)", + "Print Settings": "Параметры печати", + "Print Templates": "Шаблоны печати", + "Print to PDF": "Печать в PDF", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Профили, назначенные в настройках цвета рабочего стола, обычно лежат в ~/.local/share/icc.", + "Progressive": "Прогрессивный", + "Put New Keywords Inside": "Добавлять новые ключевые слова внутрь", + "Read Focus Points in the Background": "Читать точки фокусировки в фоне", + "Recheck Preview-Only Raws": "Перепроверить RAW, доступные только как просмотр", + "Remove Folder Color Label": "Убрать цветовую метку папки", + "Remove region": "Удалить область", + "Rename Mask Effect": "Переименовать эффект маски", + "Rename Mask Effect Group": "Переименовать группу эффектов масок", + "Rename Person": "Переименовать человека", + "Rename Person…": "Переименовать человека…", + "Rename Slideshow Template": "Переименовать шаблон слайд-шоу", + "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).": "Готовит следующие слайды, пока показывается текущий, чтобы переходы не ждали. Выключено: каждый слайд готовится, когда подходит его очередь (меньше памяти и работы).", + "Reset Print Settings": "Сбросить параметры печати", + "Reset Sliders": "Сбросить ползунки", + "Resize Print Cell": "Изменить размер ячейки печати", + "Resize region": "Изменить размер области", + "SDR Brightness": "Яркость SDR", + "SDR Clarity": "Чёткость SDR", + "SDR Contrast": "Контрастность SDR", + "SDR Highlights": "Света SDR", + "SDR Shadows": "Тени SDR", + "SDR Whites": "Белые SDR", + "Save Current Settings as New Effect Preset…": "Сохранить текущие настройки как новый пресет эффекта…", + "Save Current Settings as New Preset…": "Сохранить текущие настройки как новый пресет…", + "Save Mask Effect": "Сохранить эффект маски", + "Save Print Template": "Сохранить шаблон печати", + "Save Slideshow Template": "Сохранить шаблон слайд-шоу", + "Scan Folder for Changes": "Проверить папку на изменения", + "Search effects": "Поиск эффектов", + "Select Only Active Photo": "Выбрать только активное фото", + "Select a mask to apply or update presets.": "Выберите маску, чтобы применить или обновить пресеты.", + "Select all": "Выбрать все", + "Select none": "Снять выбор", + "Set Effect for New Masks": "Задать эффект для новых масок", + "Set Folder Color Label": "Назначить цветовую метку папке", + "Set Print Cell Photo": "Задать фото ячейки печати", + "Set Print Settings": "Задать параметры печати", + "Show / hide": "Показать / скрыть", + "Show in Compare": "Показать в сравнении", + "Show mask": "Показать маску", + "Show photos": "Показать фото", + "Show this wheel's effect": "Показать эффект этого круга", + "Slide duration": "Длительность слайда", + "Slideshow Export Status": "Состояние экспорта слайд-шоу", + "Slideshow Layout Settings": "Настройки макета слайд-шоу", + "Slideshow Options…": "Параметры слайд-шоу…", + "Slideshow Overlay Settings": "Настройки наложений слайд-шоу", + "Slideshow Playback Settings": "Настройки воспроизведения слайд-шоу", + "Slideshow Slide Preview": "Предпросмотр слайда", + "Slideshow Title Settings": "Настройки титров слайд-шоу", + "Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "«Слайд-шоу» в меню «Вид» показывает фото текущего вида, начиная с выбранного. Эти настройки сохраняются в библиотеке.", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Целевая коррекция включена: тяните по фото вверх или вниз · Esc для выхода", + "Template name": "Название шаблона", + "The layout image can't be shown": "Не удаётся показать изображение макета", + "Their photos in the grid": "Их фото в сетке", + "This page setup is not valid": "Эти параметры страницы недопустимы", "This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё", "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте", "Add to Selected Photos": "Добавить к выбранным фото", @@ -784,8 +975,28 @@ "No new presets": "Новых пресетов нет", "No photo selected": "Фото не выбрано", "No photos": "Нет фото", + "Thumbnail size": "Размер миниатюр", + "Title bar": "Строка заголовка", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Чтобы передать этому редактору сам исходный файл (RAW без копии, например для DxO PhotoLab), щёлкните фото правой кнопкой → Редактировать в → Открыть оригинал в другом редакторе (RAW, без копии).", + "Tool Overlay": "Наложение инструмента", + "Ungrouped": "Без группы", + "Unknown date": "Дата неизвестна", + "Unnamed face": "Лицо без имени", + "Update Mask Effect": "Обновить эффект маски", + "Update Slideshow Template": "Обновить шаблон слайд-шоу", + "Update to Current Process": "Обновить до текущей версии обработки", + "Use Screen Profile": "Использовать профиль экрана", + "Use sRGB": "Использовать sRGB", + "Visualize HDR": "Показать диапазон HDR", + "When pins show on the photo (Shift+H cycles)": "Когда показывать булавки на фото (Shift+H переключает)", + "White on Black": "Белое на чёрном", + "Write Waiting XMP Sidecars": "Записать ожидающие XMP-файлы", "Your library is empty": "Библиотека пуста", "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Импортированные фото появятся здесь. Ничего не копируется и не перемещается, пока вы сами этого не выберете.", + "cells": "яч.", + "cells overlap": "ячейки перекрываются", + "current edits": "текущие правки", + "or drop a .onnx file on the window": "или перетащите файл .onnx в окно", "or drop photos and folders into this window": "или перетащите фото и папки в это окно", "Import photos to get started": "Импортируйте фото, чтобы начать", "or drop photos into this window": "или перетащите фото в это окно", @@ -1371,7 +1582,12 @@ "not available here": "здесь недоступно", "of the following rules:": "из следующих условий:", "saved in": "сохранено в", + "size": "размер", + "smart album value|No": "Нет", + "smart album value|Yes": "Да", + "source": "источник", "this photo": "это фото", + "this screen's profile": "профиль этого экрана", "− harder border · + softer border": "− жёстче край · + мягче край", "Back to this person": "Вернуться к этому человеку", "AI Denoise": "ИИ-шумоподавление", @@ -2349,5 +2565,6 @@ "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, прежде чем добавлять фото" + "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото", + "★ Favorites": "★ Избранное" } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 98f8458c9..fa10210f0 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -1,7 +1,13 @@ use super::clock::{AppClock, monotonic_ms}; use super::services::Services; use super::state::{GestureFrame, InjectedDrop, InjectedPress}; -use super::*; +use super::{ + AiModel, Caches, CommandError, ControlRequest, ControlResponse, ErrorKind, GESTURE_TIMEOUT_MS, LightcraftApp, MAX_PENDING_GESTURES, + MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, MAX_TEXTURE_SIDE, PendingGesture, PendingShot, Perf, Receiver, SCREEN_PROFILE, + SCREENSHOT_FALLBACK_MS, SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, Session, ShotGeometry, + TOAST_SECONDS, UiState, Value, docking, drop_overlay, export_task, focus_scan, frame_rate, headless, i18n, import, lightroom_import, menus, + merge, model_setup, panels, pick, recheck, render, shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets, +}; mod control; mod frame; diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 00b66e4b0..525c5419c 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ControlResponse, GESTURE_TIMEOUT_MS, GestureFrame, LightcraftApp, MAX_PENDING_GESTURES, PendingGesture, Sender, Value}; use crate::control; impl LightcraftApp { diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index efae98a9b..6e92091c7 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + LightcraftApp, docking, drop_overlay, export_task, focus_scan, import, lightroom_import, merge, model_setup, monotonic_ms, panels, pick, recheck, + shortcuts, slideshow_export, state, sync, tasks, theme, updates_ui, widgets, +}; impl LightcraftApp { /// Install/refresh fonts once and watch the device and availability caches. Setup, not input. diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index f94108ce0..35b7991b4 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{GESTURE_TIMEOUT_MS, InjectedDrop, InjectedPress, LightcraftApp, drop_overlay, import, panels}; impl LightcraftApp { /// Preserve the currently queued handles while a prompt or blocking library window owns @@ -262,7 +262,7 @@ mod drop_tests { fn photo_sidecars_are_not_presets() { let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]); - let (presets, photos) = super::split_presets(dropped); + let (presets, photos) = crate::split_presets(dropped); assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); } @@ -271,31 +271,31 @@ mod drop_tests { fn detection_results_are_summarised_in_words() { let r = |counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::>()}); - assert_eq!(super::detect_summary(&r(&[0])), "No faces found"); - assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face"); - assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces"); - assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); - assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); - assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); + assert_eq!(crate::app::detect_summary(&r(&[0])), "No faces found"); + assert_eq!(crate::app::detect_summary(&r(&[1])), "Found 1 face"); + assert_eq!(crate::app::detect_summary(&r(&[3])), "Found 3 faces"); + assert_eq!(crate::app::detect_summary(&r(&[0, 0])), "No faces found in 2 photos"); + assert_eq!(crate::app::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos"); + assert_eq!(crate::app::detect_summary(&serde_json::json!({})), "No faces found in 0 photos"); } #[test] fn dropped_face_models_are_told_apart() { for p in ["/a/model.onnx", "/a/dir/M.ONNX"] { - assert!(super::is_model_file(p), "{p}"); + assert!(crate::is_model_file(p), "{p}"); } for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] { - assert!(!super::is_model_file(p), "{p}"); + assert!(!crate::is_model_file(p), "{p}"); } } #[test] fn dropped_presets_are_told_apart_from_photos() { for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] { - assert!(super::is_preset_file(p), "{p}"); + assert!(crate::is_preset_file(p), "{p}"); } for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { - assert!(!super::is_preset_file(p), "{p}"); + assert!(!crate::is_preset_file(p), "{p}"); } } } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index a85a5f751..3dcafeb14 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, SCREEN_PROFILE, theme}; impl LightcraftApp { /// The system's appearance: the host's answer first, then egui's (none: unknown). diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index c2de3aae6..79b463995 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ControlResponse, LightcraftApp, MAX_PENDING_SHOTS, MAX_SHOT_BYTES, MAX_SHOT_TOTAL_BYTES, PendingShot, SCREENSHOT_FALLBACK_MS, + SCREENSHOT_SETTLE_MS, SHOT_FORCE_BUSY_MS, SHOT_SETTLE_FRAMES, SHOT_SETTLE_MS, Sender, ShotGeometry, headless, +}; use crate::control; impl LightcraftApp { diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 0fb5a4b2c..93fe55184 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Caches, album_picker, i18n, panels}; /// The left panel's counts. #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 772303ffb..646288220 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6,6 +6,7 @@ use serde_json::json; use crate::LightcraftApp; use crate::state::Dialog; use crate::theme::Tokens; +use lightcraft_engine::export::{ExportOptions, Resize}; mod confirm; mod cull; @@ -109,7 +110,127 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { { confirm = true; } - let title: String = match &dlg { + let title = dialog_title(app, &dlg); + let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); + // The import review can be resized (its photo grid takes the room); it keeps a window id of its + // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls + // rather than growing the window when the options below the grid get taller (e.g. Copy). + let import = matches!(dlg, Dialog::Import { .. }); + // The Print dialog sizes itself to the window and scrolls its settings column on its own. + let print = matches!(dlg, Dialog::Print); + let window_id = window_id(&dlg); + // Every other dialog fits the window (#781): its options scroll when they don't, and the title + // bar and the button row stay outside the scroll area. The room they take is measured on the + // previous frame (a guess on the first one). + let chrome_id = window_id.with("chrome"); + let chrome = ctx.data(|d| d.get_temp::(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); + // Settings keeps its top edge, and the widest it has been, while tabs of different sizes are shown, so its tab + // row stays where it is (centred like the others, the row jumped with each tab's height) + let settings = matches!(dlg, Dialog::Settings { .. }); + let top = (screen.height() * 0.08).clamp(DIALOG_SCREEN_MARGIN, 96.0); + let body_max = + if settings { screen.height() - top - DIALOG_SCREEN_MARGIN - chrome } else { screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome } + .max(DIALOG_MIN_BODY); + let widest_id = window_id.with("widest"); + let widest = if settings { ctx.data(|d| d.get_temp::(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) + .vscroll(import) + .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) + .frame(frame) + .anchor(if settings { egui::Align2::CENTER_TOP } else { egui::Align2::CENTER_CENTER }, [0.0, if settings { top } else { 0.0 }]) + .min_width(widest) + .default_width(match dlg { + Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], + Dialog::SmartRules { .. } => 680.0, + Dialog::AllMetadata { .. } => 620.0, + Dialog::Print => 1100.0, + Dialog::FaceModel { .. } => 460.0, + Dialog::LightroomCatalog { .. } => 440.0, + Dialog::LightroomDestination { .. } => 480.0, + Dialog::ManageEffects { .. } => 560.0, + _ => 380.0, + }) + .show(ctx, |ui| { + // Disabled, not skipped: the dialog keeps its layout and state, but a raw event in the + // first prompt frame cannot toggle a checkbox or press a button underneath it. The + // blocking library window gets the same treatment: it owns input while it is on screen, + // so the dialog behind it must not take focus or accept typed text either. + if raw_blocked { + ui.disable(); + } + ui.spacing_mut().item_spacing.y = 8.0; + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| { + dialog_content(app, ui, &t, body_max, &mut confirm, &mut dlg) + }); + ui.add_space(4.0); + // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with + // the accent when it is the dialog's primary action. The row spans the width the window + // gives its content this frame (its default width, or its content's if wider), so it + // reaches the edge without widening the window. + let row_w = ui.available_width().max(ui.min_rect().width()).max(1.0); + let row_h = ui.spacing().interact_size.y; + ui.allocate_ui_with_layout(egui::vec2(row_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { + button_row(app, ui, &dlg, &mut close, &mut confirm); + }); + if import { + crate::import::note_dialog_bottom(ui); + } + content_w = ui.min_rect().width(); + }); + 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 !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { + close = true; + } else if raw_blocked { + // A press made under the prompt or library window must not be remembered as a + // backdrop press for a release that arrives after that covering window goes away. + ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("dialog-backdrop-press"))); + } + } + if let Some(w) = shown { + ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); + if let Some(body_h) = body_h { + let measured = (w.response.rect.height() - body_h).max(0.0); + if (measured - chrome).abs() > 0.5 { + ctx.data_mut(|d| d.insert_temp(chrome_id, measured)); + ctx.request_repaint(); + } + } + ctx.move_to_top(w.response.layer_id); + crate::widgets::register_response("dialog:window", w.response.rect, &w.response); + } + // (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in + // the dialog, such as a date picker's calendar, takes it to close itself) + if !raw_blocked && ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { + close = true; + } + if !raw_blocked && confirm { + close |= confirmed(app, ctx, &mut dlg); + } + // a dialog opened from inside this one (a button in Settings that shows a licence) takes its place + if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { + return; + } + if !close && !blocked { + 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 title of a dialog. +fn dialog_title(app: &LightcraftApp, dlg: &Dialog) -> String { + match dlg { Dialog::TextPrompt { title, .. } => title.as_str(), Dialog::NewAlbum { folder: true, .. } => "Create Folder", Dialog::NewAlbum { .. } => "Create Album", @@ -165,1326 +286,227 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { Dialog::Shortcuts => "Keyboard Shortcuts", Dialog::ViewOptions => "View Options", } - .to_string(); - let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); - // The import review can be resized (its photo grid takes the room); it keeps a window id of its - // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls - // rather than growing the window when the options below the grid get taller (e.g. Copy). - let import = matches!(dlg, Dialog::Import { .. }); - // The Print dialog sizes itself to the window and scrolls its settings column on its own. - let print = matches!(dlg, Dialog::Print); - let window_id = window_id(&dlg); - // Every other dialog fits the window (#781): its options scroll when they don't, and the title - // bar and the button row stay outside the scroll area. The room they take is measured on the - // previous frame (a guess on the first one). - let chrome_id = window_id.with("chrome"); - let chrome = ctx.data(|d| d.get_temp::(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); - // Settings keeps its top edge, and the widest it has been, while tabs of different sizes are shown, so its tab - // row stays where it is (centred like the others, the row jumped with each tab's height) - let settings = matches!(dlg, Dialog::Settings { .. }); - let top = (screen.height() * 0.08).clamp(DIALOG_SCREEN_MARGIN, 96.0); - let body_max = - if settings { screen.height() - top - DIALOG_SCREEN_MARGIN - chrome } else { screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome } - .max(DIALOG_MIN_BODY); - let widest_id = window_id.with("widest"); - let widest = if settings { ctx.data(|d| d.get_temp::(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) - .vscroll(import) - .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) - .frame(frame) - .anchor(if settings { egui::Align2::CENTER_TOP } else { egui::Align2::CENTER_CENTER }, [0.0, if settings { top } else { 0.0 }]) - .min_width(widest) - .default_width(match dlg { - Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], - Dialog::SmartRules { .. } => 680.0, - Dialog::AllMetadata { .. } => 620.0, - Dialog::Print => 1100.0, - Dialog::FaceModel { .. } => 460.0, - Dialog::LightroomCatalog { .. } => 440.0, - Dialog::LightroomDestination { .. } => 480.0, - Dialog::ManageEffects { .. } => 560.0, - _ => 380.0, - }) - .show(ctx, |ui| { - // Disabled, not skipped: the dialog keeps its layout and state, but a raw event in the - // first prompt frame cannot toggle a checkbox or press a button underneath it. The - // blocking library window gets the same treatment: it owns input while it is on screen, - // so the dialog behind it must not take focus or accept typed text either. - if raw_blocked { - ui.disable(); - } - ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| match &mut dlg { - Dialog::AutoStack { gap } => { - ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); - let r = ui.add( - egui::Slider::new(gap, 0.0..=86400.0) - .logarithmic(true) - .smallest_positive(1.0) - .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .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(); - let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; - ui.label( - 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, 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)); - ui.add_space(6.0); - let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text(crate::i18n::tr("Reject below focus")).step_by(1.0)); - crate::widgets::register(ui, "field:cullReject", r.rect); - 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, "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, "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, "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 => { - egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| { - for line in WHATS_NEW.lines() { - let l = line.trim_end(); - if let Some(h) = l.strip_prefix("### ") { - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(12.5)).color(t.text)); - } else if let Some(h) = l.strip_prefix("## ") { - ui.add_space(8.0); - ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(14.0)).color(t.text)); - } else if l.starts_with("# ") || l.is_empty() { - } else if let Some(b) = l.strip_prefix("- ") { - ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(b).replace('`', ""))).color(t.text_label)); - } else { - ui.label(egui::RichText::new(crate::i18n::tr(l.trim()).replace('`', "")).color(t.text_label)); - } - } - }); - } - Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted), - Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted), - Dialog::LightroomResult { summary, library, details, .. } => { - ui.label(summary.as_str()); - ui.add_space(8.0); - if let Some(library) = library { - ui.label(crate::i18n::tr("Destination library")); - ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); - ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); - } else { - ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); - } - for detail in details.iter() { - ui.add(egui::Label::new(detail).wrap().selectable(true)); - } - } - Dialog::SystemInfo { rows } => { - egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| { - for (k, v) in rows.iter() { - ui.label(egui::RichText::new(crate::i18n::tr(k)).color(t.text_dim)); - ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); - ui.end_row(); - } - }); - if ui.button(crate::i18n::tr("Copy to Clipboard")).clicked() { - let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect(); - ui.ctx().copy_text(text); - } - } - Dialog::AllMetadata { title, rows, search } => { - ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); - let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), crate::i18n::tr("Filter fields")); - crate::widgets::register(ui, "field:metadataSearch", r.rect); - let q = search.trim().to_lowercase(); - let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); - let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); - for row in rows["exif"].as_array().into_iter().flatten() { - let (g, n, v) = (row["group"].as_str().unwrap_or(""), row["name"].as_str().unwrap_or(""), row["value"].as_str().unwrap_or("")); - if !keep(n, v) { - continue; - } - match groups.iter_mut().find(|(x, _)| x == g) { - Some((_, list)) => list.push((n.into(), v.into())), - None => groups.push((g.into(), vec![(n.into(), v.into())])), - } - } - let xmp: Vec<(String, String)> = rows["xmp"] - .as_array() - .into_iter() - .flatten() - .map(|r| (r["name"].as_str().unwrap_or("").to_string(), r["value"].as_str().unwrap_or("").to_string())) - .filter(|(n, v)| keep(n, v)) - .collect(); - if !xmp.is_empty() { - groups.push(("XMP".into(), xmp)); - } - egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| { - if groups.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim)); - } - for (g, list) in &groups { - ui.add_space(6.0); - ui.label(egui::RichText::new(g).font(t.semibold(12.5)).color(t.text)); - egui::Grid::new(format!("meta-{g}")).num_columns(2).spacing([16.0, 3.0]).striped(true).show(ui, |ui| { - for (n, v) in list { - ui.label(egui::RichText::new(n).color(t.text_dim)); - ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); - ui.end_row(); - } - }); - } - }); - } - Dialog::SmartRules { id, name, rules, .. } => { - let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Name")); - crate::widgets::register(ui, "field:smartName", r.rect); - ui.add_space(6.0); - // the folder an album made from a folder view carries is not in the editor, but it counts - let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); - // problems, count and albums: cached until the catalog or the rules change (not per frame) - let now = (app.session.clock)(); - let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); - let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; - egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| { - crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); - }); - let n = view.count; - ui.add_space(4.0); - if view.problems.is_empty() { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - } else { - ui.label(egui::RichText::new(crate::i18n::tr("Fix the marked rules to save this album.")).color(t.caution)); - } - } - Dialog::NewSmartAlbum { name, .. } => { - if dialog_field(ui, "field:smartAlbumName", name, "Name") { - confirm = true; - } - let rules = app.session.view_rules(); - // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame - let now = (app.session.clock)(); - let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; - let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { - lightcraft_catalog::rules::set_now(Some(now.clone())); - app.session.catalog.query(&rules, &Default::default()).len() - }); - ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label)); - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - } - Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => { - let n = app.session.selected().len(); - field(ui, "Change", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { - if crate::widgets::text_button(ui, &format!("captureMode-{i}"), label, mode == m).clicked() { - *mode = m.to_string(); - } - } - }); - let params = match mode.as_str() { - "set" => { - field(ui, "New time", |ui| { - let r = ui.add(egui::TextEdit::singleline(time).hint_text("2026-09-30 14:05:00").desired_width(f32::INFINITY)); - crate::widgets::register(ui, "field:captureTime", r.rect); - }); - json!({"time": time}) - } - "shift" => { - field(ui, "Shift", |ui| { - ui.add(egui::DragValue::new(days).suffix(" d")); - ui.add(egui::DragValue::new(hours).suffix(" h")); - ui.add(egui::DragValue::new(minutes).suffix(" min")); - }); - json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}) - } - _ => { - field(ui, "Hours", |ui| ui.add(egui::DragValue::new(zone).range(-26.0..=26.0).speed(0.25).fixed_decimals(2))); - json!({"hours": zone}) - } - }; - // preview on the active photo (the others move by the same amount) - let active = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| (p.file_name.clone(), p.date().to_string())); - if let Some((name, cur)) = active { - let delta = match mode.as_str() { - "set" => lightcraft_catalog::dates::normalize_iso(time) - .and_then(|t| Some(lightcraft_catalog::dates::iso_seconds(&t)? - lightcraft_catalog::dates::iso_seconds(&cur)?)), - "shift" => params["shift"].as_i64(), - _ => Some((*zone as f64 * 3600.0).round() as i64), - }; - // 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), - ); - } - if n > 1 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim)); - } - } - Dialog::LabelNames { names, save_as } => { - names.resize(5, String::new()); - // start from a set - let sets = lightcraft_engine::cmd::manage::label_sets_json(&app.session); - field(ui, "Set", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for set in sets["sets"].as_array().into_iter().flatten() { - let name = set["name"].as_str().unwrap_or_default(); - if crate::widgets::text_button(ui, &format!("labelSet-{name}"), name, false).clicked() { - *names = set["names"].as_array().into_iter().flatten().map(|n| n.as_str().unwrap_or_default().to_string()).collect(); - } - } - }); - for (i, l) in lightcraft_catalog::ColorLabel::ALL.iter().enumerate() { - let colour = format!("{l:?}"); - field(ui, &colour, |ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); - ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l)); - let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), crate::i18n::tr(&colour)); - crate::widgets::register(ui, format!("field:labelName-{}", colour.to_lowercase()), te.rect); - }); - } - field(ui, "Save as set", |ui| { - let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), crate::i18n::tr("Save as set")); - crate::widgets::register(ui, "field:labelSetName", te.rect); - }); - ui.label( - egui::RichText::new(crate::i18n::tr("Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.")) - .color(t.text_dim), - ); - } - Dialog::Rename { template, start } => { - let n = app.session.selected().len(); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); - let template_id = egui::Id::unique("rename-template"); - let tags_open = field(ui, "Template", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = crate::import::tag_field_width(ui); - let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), crate::i18n::tr("Template")); - crate::widgets::register(ui, "field:renameTemplate", r.rect); - crate::import::tag_toggle(ui, "renameTemplate") - }); - if tags_open { - crate::import::tag_help(ui, "renameTemplate", template, template_id); - } - crate::import::unknown_tags_warning(ui, template); - field(ui, "Presets", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (label, tpl)) in - [("Name", "{name}"), ("Date–Name", "{date}_{name}"), ("Title–Seq", "{title}-{seq:3}"), ("Custom–Seq", "Photo-{seq:3}")].iter().enumerate() - { - if crate::widgets::text_button(ui, &format!("renamePreset-{i}"), label, template == tpl).clicked() { - *template = tpl.to_string(); - } - } - }); - field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); - ui.label( - egui::RichText::new(crate::i18n::tr("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")) - .color(t.text_dim), - ); - let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize); - match &preview { - Some(rows) => { - egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { - for pl in rows { - ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); - let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); - crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); - ui.label(egui::RichText::new(&pl.to).color(t.text)); - ui.end_row(); - } - }); - } - None => { - 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); - if more > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); - } - } - Dialog::RenamePerson { from, to, merge } => { - let submitted = dialog_field(ui, "field:personName", to, "Name"); - let plan = person_rename_plan(&app.session.catalog, from, to); - if !plan.needs_merge { - *merge = false; - } - if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { - confirm = true; - } - if let Some(why) = plan.block { - ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); - } else { - ui.label( - egui::RichText::new(crate::i18n::tr_format!( - "Renames {n} face{} across the library. The files are not changed.", - if plan.faces == 1 { "" } else { "s" }, - n = plan.faces - )) - .color(t.text_dim), - ); - } - if plan.needs_merge { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("“{to}” is already a person. Combining them makes one person; Undo separates them again.", to = to.trim())) - .color(t.caution), - ); - let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); - crate::widgets::register(ui, "check:personMerge", r.rect); - } - } - Dialog::RenameKeyword { from, to } => { - let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { - app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() - }); - if dialog_field(ui, "field:keywordName", to, "New name") { - confirm = true; - } - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.", - if n == 1 { "" } else { "s" }, from = from, n = n)) - .color(t.text_dim), - ); - } - Dialog::MergeKeywords { from, into } => { - ui.label(egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))).color(t.text_label)); - if dialog_field(ui, "field:keywordInto", into, "Keyword") { - confirm = true; - } - let options: Vec = app - .caches - .suggestions(&app.session.catalog, from, into, 12) - .iter() - .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) - .cloned() - .collect(); - ui.horizontal_wrapped(|ui| { - for k in options { - if ui.add(egui::Button::new(egui::RichText::new(&k).color(t.text_label)).corner_radius(10.0)).clicked() { - *into = k; - } - } - }); - } - Dialog::TextPrompt { value, hint, .. } => { - if dialog_field(ui, "field:prompt", value, hint) { - confirm = true; - } - } - Dialog::NewAlbum { name, folder, include_selected, .. } => { - if dialog_field(ui, "field:albumName", name, "Name") { - confirm = true; - } - // as in Lightroom, the option is only there when something is selected - if !*folder && !app.session.selected().is_empty() { - ui.add_space(6.0); - let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); - crate::widgets::register(ui, "check:albumIncludeSelected", r.rect); - } - } - Dialog::RenameAlbum { name, .. } => { - if dialog_field(ui, "field:albumName", name, "Name") { - confirm = true; - } - } - Dialog::CreatePreset { name, group, groups } => { - crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), crate::i18n::tr("Preset name")); - crate::widgets::named(ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), crate::i18n::tr("Group")); - ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); - group_checklist(ui, "presetInclude", groups); - } - Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), - Dialog::PasteSettings { groups } => { - let n = app.session.selected().len(); - ui.label( - egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n)) - .color(t.text_dim), - ); - group_checklist(ui, "pasteGroup", groups); - } - Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), - Dialog::ContactSheet { options } => { - options.columns = options.columns.clamp(1, 8); - options.rows = options.rows.clamp(1, 10); - let n = app.session.selected().len(); - ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); - field(ui, "Paper", |ui| { - let c = crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { - ui.selectable_value(&mut options.paper, "a4".into(), "A4"); - ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); - }); - crate::widgets::name_combo(&c.response, crate::i18n::tr("Paper")); - }); - ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); - field(ui, "Columns", |ui| { ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); }); - field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); }); - ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); - ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); - ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); - } - Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { - use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; - let n = app.session.selection.ids.len().max(1); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); - // Preset: load a built-in or saved set of options into the dialog - field(ui, "Preset", |ui| { - let mut chosen = None; - let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { - let mut builtin = true; - for (p, b) in app.session.all_export_presets() { - if builtin && !b { - ui.separator(); - } - builtin = b; - if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { - chosen = Some(p.name); - } - } - }); - crate::widgets::name_combo(&c.response, crate::i18n::tr("Preset")); - crate::widgets::register(ui, "combo:exportPreset", c.response.rect); - if let Some(name) = chosen - && let Ok(params) = app.session.export_params(&json!({"preset": name})) - { - let o = lightcraft_engine::export::ExportOptions::from_json(¶ms); - *full_size = o.resize.is_none(); - *resize = o.resize.unwrap_or_default(); - *limit_kb = o.limit_kb.unwrap_or(0); - *opts = o; - } - }); - ui.add_space(4.0); - let before = opts.format; - // "Rendered DNG" is a DNG option (below), not a format of its own in this list - let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; - choices( - ui, - "Format", - "exportFormat", - &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Jxl, "JPEG XL"), (F::Dng, "DNG"), (F::Original, "Original files")], - &mut shown, - ); - if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { - opts.format = shown; - } - if matches!(opts.format, F::Dng | F::RenderedDng) { - choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); - } - if !opts.format.is_rendered() { - let note = if opts.format == F::Dng { - "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." - } else { - "The original files, unchanged, each with an XMP sidecar holding its edits." - }; - ui.label(egui::RichText::new(note).color(t.text_dim)); - } - if opts.format != before { - // each format starts at its own default depth (TIFF 16-bit, others 8-bit) - opts.bit_depth = None; - } - let rendered = opts.format.is_rendered(); - let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); - if rendered && depths.len() > 1 { - let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); - choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); - opts.bit_depth = Some(bd); - } - if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { - let tip = crate::i18n::tr("Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"); - ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); - if opts.hdr && !opts.hdr_output() { - ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); - } - if opts.hdr && opts.format == F::Avif { - use lightcraft_engine::export::HdrCurve as H; - choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); - } - } - if !rendered { - } else if opts.format == F::Avif && opts.hdr { - let label = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { - crate::i18n::tr("Color space: Rec. 2020 HLG (HDR AVIF)") - } else { - crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)") - }; - ui.label(egui::RichText::new(label).color(t.text_dim)); - } else if opts.format == F::Png && opts.hdr { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) - .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); - } else if opts.format == F::Jxl && opts.hdr { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); - } else if opts.format == F::Avif { - ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); - } else { - use lightcraft_engine::export::OutputSpace as C; - choices( - ui, - "Color space", - "exportColorSpace", - &[ - (C::Srgb, "sRGB"), - (C::DisplayP3, "P3"), - (C::AdobeRgb, "Adobe RGB"), - (C::ProPhoto, "ProPhoto"), - (C::Rec2020, "Rec.2020"), - ], - &mut opts.color_space, - ); - if opts.effective_space() != opts.color_space { - let note = "Lossy JPEG XL writes Adobe RGB and ProPhoto as Rec. 2020; choose Lossless to keep them."; - ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); - } - } - if opts.format == F::Jxl { - let tip = "Mathematically lossless: every pixel is kept exactly (larger files)"; - ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip)); - } - if matches!(opts.format, F::Jpeg | F::Avif) || (opts.format == F::Jxl && !opts.jxl_lossless) { - let mut q = opts.quality as f64; - if num(ui, &QUALITY, &mut q) { - opts.quality = q as u8; - } - } - if opts.format == F::Jxl { - use lightcraft_engine::export::JxlEffort as E; - choices(ui, "Effort", "exportJxlEffort", &[(E::Fast, "Fast"), (E::Normal, "Normal")], &mut opts.jxl_effort); - let tip = "Shows a coarse version first and refines it as the file loads"; - ui.checkbox(&mut opts.jxl_progressive, crate::i18n::tr("Progressive")).on_hover_text(crate::i18n::tr(tip)); - } - if opts.format == F::Jpeg { - let mut k = *limit_kb as f64; - if num(ui, &LIMIT_KB, &mut k) { - *limit_kb = k as u32; - } - } - if rendered { - export_size(ui, full_size, resize, &mut opts.ppi); - } - if rendered { - choices( - ui, - "Sharpen", - "exportSharpen", - &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], - &mut opts.sharpen, - ); - if opts.sharpen != S::None { - choices( - ui, - "Amount", - "exportSharpenAmount", - &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], - &mut opts.sharpen_amount, - ); - } - } - if rendered { - choices( - ui, - "Metadata", - "exportMetadata", - &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], - &mut opts.metadata, - ); - if !matches!(opts.metadata, M::None | M::Copyright) { - ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); - } - let mut wm_on = opts.watermark.is_some(); - if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { - opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); - } - let wm_shown = opts.watermark.clone(); - if let Some(wm) = &wm_shown { - watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); - } - if let Some(wm) = &mut opts.watermark { - // text, or a graphic (a logo with transparency) - let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); - let before = graphic; - choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); - if graphic != before { - ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); - if !graphic { - wm.image.clear(); - } - } - if graphic { - field(ui, "Graphic", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_files.is_some() - && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() - && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) - { - wm.image = f; - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), crate::i18n::tr("Image")); - }); - }); - let mut width = wm.image_width as f64 * 100.0; - if num(ui, &WM_IMAGE_WIDTH, &mut width) { - wm.image_width = (width / 100.0) as f32; - } - } else { - choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical); - field(ui, "Text", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Text")) - }); - } - choices( - ui, - "Position", - "exportWmAnchor", - &WM_POSITIONS, - &mut wm.anchor, - ); - choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); - let mut size = wm.size as f64 * 100.0; - if !graphic && num(ui, &WM_SIZE, &mut size) { - wm.size = (size / 100.0) as f32; - } - let mut op = wm.opacity as f64 * 100.0; - if num(ui, &WM_OPACITY, &mut op) { - wm.opacity = (op / 100.0) as f32; - } - // like Lightroom Classic: a margin from the side edges and one from the top and bottom - // edges, each unused (greyed) when the position is centred along that axis - use lightcraft_engine::export::Anchor as P; - let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { - wm.inset_x = (v / 100.0) as f32; - } - let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); - if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { - wm.inset_y = (v / 100.0) as f32; - } - if !graphic { - ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); - } - } - if opts.watermark != wm_shown { - // the preview above was drawn with the old settings - ui.ctx().request_repaint(); - } - } - ui.add_space(4.0); - if opts.format == F::Tiff { - use lightcraft_engine::export::TiffCompression as Z; - choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); - } - if opts.format == F::Dng { - use lightcraft_engine::export::DngCompression as Z; - choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], &mut opts.dng_compression); - } - let naming_id = egui::Id::unique("export-naming"); - let tags_open = field(ui, "File name", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = crate::import::tag_field_width(ui); - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)), crate::i18n::tr("File name")); - crate::import::tag_toggle(ui, "exportNaming") - }); - if tags_open { - crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id); - } - crate::import::unknown_tags_warning(ui, &opts.naming); - if opts.naming.contains("{seq") { - let mut v = opts.start_number as f64; - if num(ui, &START_NUMBER, &mut v) { - opts.start_number = v as u32; - } - } - // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on - // the web, where originals live in the browser's storage, not in folders) - use lightcraft_engine::export::DestinationMode as D; - if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder { - choices( - ui, - "Export to", - "exportDestinationMode", - &[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")], - &mut opts.destination_mode, - ); - } - if opts.destination_mode == D::SpecificFolder { - field(ui, "Folder", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() - && let Some(pick) = app.services.pick_folder.as_mut() - && let Some(d) = pick() - { - *dir = d; - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); - }); - }); - } - field(ui, "Subfolder", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Subfolder")) - }); - use lightcraft_engine::export::Conflict as K; - choices( - ui, - "If file exists", - "exportConflict", - &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], - &mut opts.conflict, - ); - // After export: Apple Photos imports the files written (macOS only) - #[cfg(target_os = "macos")] - { - let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); - crate::widgets::register(ui, "exportAddToPhotos", r.rect); - if opts.add_to_photos { - field(ui, "Album", |ui| { - crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Album")) - }); - ui.label( - egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) - .size(11.0) - .color(t.text_dim), - ); - } - } - // nowhere to add them: a setting carried over from a Mac is dropped, not refused - #[cfg(not(target_os = "macos"))] - { - opts.add_to_photos = false; - } - ui.add_space(4.0); - field(ui, "Save preset", |ui| { - trailing_button_row(ui, |ui| { - let named = !preset_name.trim().is_empty(); - if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { - let params = export_dialog_params(opts, *full_size, resize, *limit_kb); - match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { - Ok(_) => { - app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); - preset_name.clear(); - } - Err(e) => app.toast(ui.ctx(), e), - } - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), crate::i18n::tr("Preset name")); - }); - }); - } - Dialog::Merge { opts } => crate::merge::body(app, ui, opts), - Dialog::Import { opts } => crate::import::body(app, ui, opts), - // (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, - name, - parent, - inside, - synonyms, - include_on_export, - export_containing, - export_synonyms, - person, - add_to_selected, - .. - } => { - ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); - // several fields: the name takes the focus when the dialog opens, not after - let r = crate::text_field::TextField::singleline("field:keywordTagName", name) - .hint(crate::i18n::tr("Keyword Name")) - .width(f32::INFINITY) - .select_on_focus(true) - .show(ui); - if ui.is_enabled() && just_opened(ui, "keyword-tag") { - r.response.request_focus(); - } - if r.ending == Some(crate::text_field::Ending::Return) { - confirm = true; - } - ui.add_space(4.0); - ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); - // Return in either field creates (or saves) the keyword - let r = crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) - .hint(crate::i18n::tr("Separated by commas")) - .width(f32::INFINITY) - .show(ui); - if r.ending == Some(crate::text_field::Ending::Return) { - confirm = true; - } - ui.add_space(6.0); - if editing.is_none() { - if let Some(p) = parent { - let r = ui.checkbox(inside, crate::i18n::tr_format!("Put inside “{parent}”", parent = p.replace('|', " › "))); - crate::widgets::register(ui, "check:keywordInside", r.rect); - } - let n = app.session.selection.ids.len(); - let r = ui.add_enabled(n > 0, egui::Checkbox::new(add_to_selected, crate::i18n::tr("Add to Selected Photos"))); - crate::widgets::register(ui, "check:keywordAddToSelected", r.rect); - ui.add_space(4.0); - } - ui.label(egui::RichText::new(crate::i18n::tr("Keyword Tag Options")).color(t.text_dim)); - for (on, label, id) in [ - (include_on_export, "Include on Export", "check:keywordIncludeOnExport"), - (export_containing, "Export Containing Keywords", "check:keywordExportContaining"), - (export_synonyms, "Export Synonyms", "check:keywordExportSynonyms"), - (person, "Person", "check:keywordPerson"), - ] { - let r = ui.checkbox(on, crate::i18n::tr(label)); - crate::widgets::register(ui, id, r.rect); - } - } - Dialog::KeywordSet { replaces, name, slots, as_new } => { - ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim)); - let r = crate::text_field::TextField::singleline("field:keywordSetName", name) - .hint(crate::i18n::tr("Set name")) - .width(f32::INFINITY) - .select_on_focus(true) - .show(ui); - if ui.is_enabled() && just_opened(ui, "keyword-set") { - r.response.request_focus(); - } - let mut returned = r.ending == Some(crate::text_field::Ending::Return); - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::tr("Keywords (⌥1–⌥9)")).color(t.text_dim)); - slots.resize(9, String::new()); - // equal columns of a set width (the window is still finding its own) - let w = 130.0; - egui::Grid::new("keyword-set-slots").num_columns(3).min_col_width(w).spacing([6.0, 4.0]).show(ui, |ui| { - for (i, slot) in slots.iter_mut().enumerate() { - let r = crate::text_field::TextField::singleline(&format!("field:keywordSetSlot:{}", i + 1), slot) - .hint(format!("⌥{}", i + 1)) - .width(w) - .show(ui); - returned |= r.ending == Some(crate::text_field::Ending::Return); - if i % 3 == 2 { - ui.end_row(); - } - } - }); - if replaces.is_some() { - ui.add_space(6.0); - let r = ui.checkbox(as_new, crate::i18n::tr("Save as a new set")); - crate::widgets::register(ui, "check:keywordSetAsNew", r.rect); - } - // Return in any field saves the set - if returned { - confirm = true; - } - } - Dialog::MoveKeyword { keyword, parent } => { - let name = keyword.rsplit('|').next().unwrap_or(keyword); - match parent { - Some(p) => ui.label(crate::i18n::tr_format!( - "“{parent}” has a “{name}” already. Merge the two?", - parent = p.replace('|', " › "), - name = name - )), - None => ui.label(crate::i18n::tr_format!("There is a “{name}” at the top level already. Merge the two?", name = name)), - }; - ui.label(egui::RichText::new(crate::i18n::tr("Their photos and the keywords below them come together under one keyword.")).color(t.text_dim)); - } - Dialog::DeleteKeyword { keyword, count } => { - let name = keyword.replace('|', " › "); - ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); - ui.label( - egui::RichText::new(if *count == 0 { - crate::i18n::tr("No photo has it; the keywords below it go too.").to_string() - } else { - crate::i18n::tr_format!( - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.", - if *count == 1 { "" } else { "s" }, - count = count - ) - }) - .color(t.text_dim), - ); - } - Dialog::SaveEffect { name, group, fields, amount, brush } => { - if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { - confirm = true; - } - } - Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { - crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) - } - Dialog::DeleteEffects { names } => { - let what = match names.as_slice() { - [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), - _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), - }; - ui.label(what); - ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); - } - Dialog::ConfirmDelete { count } => { - let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; - ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); - ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); - } - Dialog::MovePhotos { ids, name, dont_ask, .. } => { - let n = ids.len(); - ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); - ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); - ui.add_space(4.0); - let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); - crate::widgets::register(ui, "check:moveDontAsk", c.rect); - c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); - } - d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), - 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)); - // (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?", - if *count == 1 { "" } else { "s" }, - name = name, - count = count - )); - ui.label(egui::RichText::new(path.as_str()).color(t.text_dim)); - let scope = if *disk { "All the photos imported from this disk are included." } else { "That includes the photos in the folders inside it." }; - ui.label(egui::RichText::new(crate::i18n::tr(scope)).color(t.text_dim)); - ui.label( - egui::RichText::new(crate::i18n::tr("They move to Recently Deleted and can be restored; no file on disk is touched.")) - .color(t.text_dim), - ); - } - Dialog::About => { - ui.set_min_width(680.0); - let tab_id = egui::Id::unique("about_tab"); - let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - for (i, (id, label)) in ABOUT_TABS.iter().enumerate() { - if crate::widgets::text_button(ui, &format!("aboutTab-{id}"), label, usize::from(tab) == i).clicked() { - tab = u8::try_from(i).unwrap_or(0); - } - } - }); - ui.data_mut(|d| d.insert_temp(tab_id, tab)); - ui.separator(); - match tab { - 1 => crate::credits::contributors_ui(app, ui), - 2 => crate::credits::models_ui(ui), - _ => { - ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); - ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); - ui.label(crate::i18n::tr_format!( - "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", - crate::theme::font_credits(app.chinese_font.is_some()) - )); - ui.add_space(10.0); - let r = crate::button::Button::new("Join the ArtCraft Discord") - .primary() - .large() - .min_width(260.0) - .widget("button:aboutDiscord") - .tooltip(crate::links::DISCORD) - .show(ui); - if r.clicked() { - let _ = crate::links::open(app, crate::links::DISCORD); - } - ui.add_space(6.0); - for (label, url) in [ - ("LightCraft website", crate::links::APP_PAGE), - ("Source code on GitHub", crate::links::GITHUB), - ("ArtCraft — more creative apps", crate::links::WEBSITE), - ] { - let r = ui.link(crate::i18n::tr(label)).on_hover_text(url); - if r.clicked() { - let _ = crate::links::open(app, url); - } - } - } - } - } - Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), - Dialog::ViewOptions => crate::panels::settings::view_options(app, ui, &t), - Dialog::Print => crate::print_view::body(app, ui, &t), - }); - ui.add_space(4.0); - // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with - // the accent when it is the dialog's primary action. The row spans the width the window - // gives its content this frame (its default width, or its content's if wider), so it - // reaches the edge without widening the window. - let row_w = ui.available_width().max(ui.min_rect().width()).max(1.0); - let row_h = ui.spacing().interact_size.y; - ui.allocate_ui_with_layout(egui::vec2(row_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { - // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that - // only shows something (one Close, no Cancel / OK) - let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); - let informational = unusable_model || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || only_shows(&dlg); - let sam = &app.session.segmenter; - let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); - // no download location in this build: nothing to offer but the manual install - let sam_nowhere = sam_by_hand(sam); - let cancel = match &dlg { - Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close", - Dialog::SamModel { .. } => "Not Now", - Dialog::LightroomResult { .. } => "Close", - _ => "Cancel", - }; - let add_label; - let ok = match &dlg { - Dialog::Import { opts } => { - let n = opts.selected_paths().len(); - let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; - add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); - add_label.as_str() - } - Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", - Dialog::ContactSheet { .. } => "Choose PDF…", - Dialog::SlideshowExport { .. } => "Export…", - Dialog::Print => "Print to PDF…", - Dialog::Merge { .. } => "Merge", - Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", - Dialog::DenoiseModel { .. } => "Install", - Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", - Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", - Dialog::SaveEffect { .. } => "Create", - Dialog::MovePhotos { .. } => "Move", - // (not "Merge": that is photo merging, HDR / panorama, in some languages) - Dialog::MoveKeyword { .. } => "Merge Keywords", - Dialog::KeywordSet { .. } => "Save", - Dialog::KeywordTag { editing: None, .. } => "Create", - 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", - Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", - Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again", - Dialog::SamModel { .. } => "Download", - Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", - _ if informational => "Close", - _ => "OK", - }; - // installing waits for the licence to be accepted - // (and Synchronize waits for the scan) - let rename_blocked = match &dlg { - Dialog::RenamePerson { from, to, merge } => { - let plan = person_rename_plan(&app.session.catalog, from, to); - plan.block.is_some() || (plan.needs_merge && !*merge) - } - _ => false, - }; - let can_confirm = !rename_blocked - && (informational - || !matches!( - &dlg, - Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } - ) - // smart-album rules that can't mean anything wait to be fixed - && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty())); - let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; - use crate::button::{Button, Kind}; - // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a - // dialog to the one it opens) - let kind = std::mem::discriminant(&dlg); - let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); - let ok_button = Button::new(ok) - .kind(if primary { Kind::Primary } else { Kind::Secondary }) - .enabled(can_confirm) - .widget("button:dialogOk") - .id_salt(kind) - .min_width(72.0); - // (Cancel first, then the confirm: the keyboard goes through them as the eye does) - let gap = ui.spacing().item_spacing.x; - let mut buttons_w = 0.0; - if !informational { - buttons_w += cancel_button.desired_size(ui).x + gap; - } - if !ok.is_empty() { - buttons_w += ok_button.desired_size(ui).x; - } - ui.add_space((ui.available_width() - buttons_w).max(0.0)); - if !informational && cancel_button.show(ui).clicked() { - close = true; - } - let r = (!ok.is_empty()).then(|| ok_button.show(ui)); - if r.is_some_and(|r| r.clicked()) { - if informational { - close = true; - } else { - confirm = true; - } - } - }); - if import { - crate::import::note_dialog_bottom(ui); - } - content_w = ui.min_rect().width(); - }); - 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 !raw_blocked && app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { - close = true; - } else if raw_blocked { - // A press made under the prompt or library window must not be remembered as a - // backdrop press for a release that arrives after that covering window goes away. - ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("dialog-backdrop-press"))); + .to_string() +} + +/// Whether a dialog only shows something: a model file LightCraft cannot use has nothing to confirm, nor has a +/// dialog that only shows something (one Close, no Cancel / OK). +fn is_informational(dlg: &Dialog) -> bool { + let unusable_model = matches!(dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); + unusable_model || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || only_shows(dlg) +} + +/// What the SAM 3 download looks like: installed, running, failed, and whether there is nowhere to download from. +struct SamState { + installed: bool, + running: bool, + failed: bool, + /// no download location in this build: nothing to offer but the manual install + by_hand: bool, +} + +impl SamState { + fn of(app: &LightcraftApp) -> Self { + let sam = &app.session.segmenter; + SamState { + installed: sam.installed(), + running: sam.download_status().running, + failed: sam.download_status().error.is_some(), + by_hand: sam_by_hand(sam), } } - if let Some(w) = shown { - ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); - if let Some(body_h) = body_h { - let measured = (w.response.rect.height() - body_h).max(0.0); - if (measured - chrome).abs() > 0.5 { - ctx.data_mut(|d| d.insert_temp(chrome_id, measured)); - ctx.request_repaint(); - } +} + +/// The label of a dialog's cancel button. +fn cancel_label(dlg: &Dialog, sam: &SamState) -> &'static str { + match dlg { + Dialog::SamModel { .. } if sam.running || sam.installed || sam.by_hand => "Close", + Dialog::SamModel { .. } => "Not Now", + Dialog::LightroomResult { .. } => "Close", + _ => "Cancel", + } +} + +/// The label of a dialog's confirm button (empty: it has none). +fn ok_label(app: &LightcraftApp, dlg: &Dialog, informational: bool, sam: &SamState) -> String { + let label = match dlg { + Dialog::Import { opts } => { + let n = opts.selected_paths().len(); + let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; + return crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); } - ctx.move_to_top(w.response.layer_id); - crate::widgets::register_response("dialog:window", w.response.rect, &w.response); + Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", + Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::SlideshowExport { .. } => "Export…", + Dialog::Print => "Print to PDF…", + Dialog::Merge { .. } => "Merge", + Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", + Dialog::DenoiseModel { .. } => "Install", + Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", + Dialog::FaceModel { .. } if !informational => "Install", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", + Dialog::SaveEffect { .. } => "Create", + Dialog::MovePhotos { .. } => "Move", + // (not "Merge": that is photo merging, HDR / panorama, in some languages) + Dialog::MoveKeyword { .. } => "Merge Keywords", + Dialog::KeywordSet { .. } => "Save", + Dialog::KeywordTag { editing: None, .. } => "Create", + 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 { .. } => return sam_ok_label(dlg, sam).to_string(), + Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", + _ if informational => "Close", + _ => "OK", + }; + label.to_string() +} + +/// The label of the SAM 3 dialog's confirm button. +fn sam_ok_label(dlg: &Dialog, sam: &SamState) -> &'static str { + match dlg { + Dialog::SamModel { then: Some(_), .. } if sam.installed => "Continue", + Dialog::SamModel { .. } if sam.installed || sam.running || sam.by_hand => "", + Dialog::SamModel { error, .. } if error.is_some() || sam.failed => "Try Again", + _ => "Download", } - // (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in - // the dialog, such as a date picker's calendar, takes it to close itself) - if !raw_blocked && ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { - close = true; +} + +/// Whether the confirm button works: installing waits for the licence to be accepted (and Synchronize waits for the +/// scan), a rename waits for its conflicts to be settled, smart-album rules that can't mean anything wait to be fixed. +fn can_confirm(app: &LightcraftApp, dlg: &Dialog, informational: bool) -> bool { + let rename_blocked = match dlg { + Dialog::RenamePerson { from, to, merge } => { + let plan = person_rename_plan(&app.session.catalog, from, to); + plan.block.is_some() || (plan.needs_merge && !*merge) + } + _ => false, + }; + let waiting = matches!( + dlg, + Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } + ); + let bad_rules = + matches!(dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); + !rename_blocked && (informational || (!waiting && !bad_rules)) +} + +/// The row of buttons under a dialog: Cancel and the confirm; `close` and `confirm` are set when one is clicked. +fn button_row(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &Dialog, close: &mut bool, confirm: &mut bool) { + let informational = is_informational(dlg); + let sam = SamState::of(app); + let cancel = cancel_label(dlg, &sam); + let ok = ok_label(app, dlg, informational, &sam); + let ok = ok.as_str(); + let can_confirm = can_confirm(app, dlg, informational); + let primary = !informational && can_confirm && confirm_style(dlg) == ConfirmStyle::Primary; + use crate::button::{Button, Kind}; + // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a + // dialog to the one it opens) + let kind = std::mem::discriminant(dlg); + let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); + let ok_button = Button::new(ok) + .kind(if primary { Kind::Primary } else { Kind::Secondary }) + .enabled(can_confirm) + .widget("button:dialogOk") + .id_salt(kind) + .min_width(72.0); + // (Cancel first, then the confirm: the keyboard goes through them as the eye does) + let gap = ui.spacing().item_spacing.x; + let mut buttons_w = 0.0; + if !informational { + buttons_w += cancel_button.desired_size(ui).x + gap; } - if !raw_blocked && 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, and Edit Capture Time - // (a time that isn't a date, or a shift it refuses) - Err(e) - if matches!( - dlg, - Dialog::Import { .. } - | Dialog::Export { .. } - | Dialog::ContactSheet { .. } - | Dialog::SlideshowExport { .. } - | Dialog::Print - | Dialog::NewAlbum { .. } - | Dialog::NewSmartAlbum { .. } - | Dialog::SmartRules { .. } - | Dialog::KeywordTag { .. } - | Dialog::KeywordSet { .. } - | Dialog::SynchronizeFolder { .. } - | Dialog::SaveEffect { .. } - | Dialog::CaptureTime { .. } - ) => - { - app.toast(ctx, e) - } - // the SAM 3 dialog stays open to show the download (or why it can't start) - Err(e) if matches!(dlg, Dialog::SamModel { .. }) => { - if let Dialog::SamModel { error, .. } = &mut dlg { - *error = Some(e); - } - } - Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { - // albums hold library photos (issue #684): say which part of the selection stayed out - crate::panels::grid::toast_skipped(app, ctx, &r); - close = true; - } - // a move says how it went, or why nothing moved - Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { - if let Dialog::MovePhotos { name, .. } = &dlg { - let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); - app.toast(ctx, text); - } - close = true; - } - Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { - 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; - } - } - _ => close = true, + if !ok.is_empty() { + buttons_w += ok_button.desired_size(ui).x; + } + ui.add_space((ui.available_width() - buttons_w).max(0.0)); + if !informational && cancel_button.show(ui).clicked() { + *close = true; + } + let r = (!ok.is_empty()).then(|| ok_button.show(ui)); + if r.is_some_and(|r| r.clicked()) { + if informational { + *close = true; + } else { + *confirm = true; } } - // a dialog opened from inside this one (a button in Settings that shows a licence) takes its place - if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { - return; +} + +/// The dialog's confirm was pressed: do it. Whether the dialog closes. +fn confirmed(app: &mut LightcraftApp, ctx: &egui::Context, dlg: &mut Dialog) -> bool { + let mut closed = false; + 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, and Edit Capture Time + // (a time that isn't a date, or a shift it refuses) + Err(e) + if matches!( + dlg, + Dialog::Import { .. } + | Dialog::Export { .. } + | Dialog::ContactSheet { .. } + | Dialog::SlideshowExport { .. } + | Dialog::Print + | Dialog::NewAlbum { .. } + | Dialog::NewSmartAlbum { .. } + | Dialog::SmartRules { .. } + | Dialog::KeywordTag { .. } + | Dialog::KeywordSet { .. } + | Dialog::SynchronizeFolder { .. } + | Dialog::SaveEffect { .. } + | Dialog::CaptureTime { .. } + ) => + { + app.toast(ctx, e) + } + // the SAM 3 dialog stays open to show the download (or why it can't start) + Err(e) if matches!(dlg, Dialog::SamModel { .. }) => { + if let Dialog::SamModel { error, .. } = dlg { + *error = Some(e); + } + } + Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { + // albums hold library photos (issue #684): say which part of the selection stayed out + crate::panels::grid::toast_skipped(app, ctx, &r); + closed = true; + } + // a move says how it went, or why nothing moved + Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { + if let Dialog::MovePhotos { name, .. } = dlg { + let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); + app.toast(ctx, text); + } + closed = true; + } + Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { + app.toast(ctx, e); + closed = 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")); + closed = 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 { .. }) => { + closed = !crate::lightroom_destination::keeps_open_on_error(app, dlg); + app.toast(ctx, e); + } + Ok(_) if keeps_open(app, dlg) => { + if let Dialog::SamModel { error, .. } = dlg { + *error = None; + } + } + _ => closed = true, } - if !close && !blocked { - 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) }; + + closed } /// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`]. @@ -1495,3 +517,1330 @@ const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool { matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed() } + +/// The body of the AutoStack dialog. +fn auto_stack_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::AutoStack { gap } = dlg else { return }; + ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); + let r = ui.add( + egui::Slider::new(gap, 0.0..=86400.0) + .logarithmic(true) + .smallest_positive(1.0) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .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(); + let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; + ui.label( + 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), + ); +} + +/// The body of the Cull dialog. +fn cull_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Cull { reject_below, pick_best, burst_gap, similarity } = dlg else { return }; + 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), + ); + ui.add_space(6.0); + let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text(crate::i18n::tr("Reject below focus")).step_by(1.0)); + crate::widgets::register(ui, "field:cullReject", r.rect); + 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, "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, "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, "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), + ); +} + +/// The body of the WhatsNew dialog. +fn whats_new_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::WhatsNew = dlg else { return }; + egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| { + for line in WHATS_NEW.lines() { + let l = line.trim_end(); + if let Some(h) = l.strip_prefix("### ") { + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(12.5)).color(t.text)); + } else if let Some(h) = l.strip_prefix("## ") { + ui.add_space(8.0); + ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(14.0)).color(t.text)); + } else if l.starts_with("# ") || l.is_empty() { + } else if let Some(b) = l.strip_prefix("- ") { + ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(b).replace('`', ""))).color(t.text_label)); + } else { + ui.label(egui::RichText::new(crate::i18n::tr(l.trim()).replace('`', "")).color(t.text_label)); + } + } + }); +} + +/// The body of the LightroomResult dialog. +fn lightroom_result_body(ui: &mut egui::Ui, dlg: &mut Dialog) { + let Dialog::LightroomResult { summary, library, details, .. } = dlg else { return }; + ui.label(summary.as_str()); + ui.add_space(8.0); + if let Some(library) = library { + ui.label(crate::i18n::tr("Destination library")); + ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); + ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); + } else { + ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); + } + for detail in details.iter() { + ui.add(egui::Label::new(detail).wrap().selectable(true)); + } +} + +/// The body of the SystemInfo dialog. +fn system_info_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::SystemInfo { rows } = dlg else { return }; + egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| { + for (k, v) in rows.iter() { + ui.label(egui::RichText::new(crate::i18n::tr(k)).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); + ui.end_row(); + } + }); + if ui.button(crate::i18n::tr("Copy to Clipboard")).clicked() { + let text: String = rows.iter().map(|(k, v)| format!("{k}: {v}\n")).collect(); + ui.ctx().copy_text(text); + } +} + +/// The body of the AllMetadata dialog. +fn all_metadata_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::AllMetadata { title, rows, search } = dlg else { return }; + ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), + crate::i18n::tr("Filter fields"), + ); + crate::widgets::register(ui, "field:metadataSearch", r.rect); + let q = search.trim().to_lowercase(); + let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); + let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for row in rows["exif"].as_array().into_iter().flatten() { + let (g, n, v) = (row["group"].as_str().unwrap_or(""), row["name"].as_str().unwrap_or(""), row["value"].as_str().unwrap_or("")); + if !keep(n, v) { + continue; + } + match groups.iter_mut().find(|(x, _)| x == g) { + Some((_, list)) => list.push((n.into(), v.into())), + None => groups.push((g.into(), vec![(n.into(), v.into())])), + } + } + let xmp: Vec<(String, String)> = rows["xmp"] + .as_array() + .into_iter() + .flatten() + .map(|r| (r["name"].as_str().unwrap_or("").to_string(), r["value"].as_str().unwrap_or("").to_string())) + .filter(|(n, v)| keep(n, v)) + .collect(); + if !xmp.is_empty() { + groups.push(("XMP".into(), xmp)); + } + egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| { + if groups.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim)); + } + for (g, list) in &groups { + ui.add_space(6.0); + ui.label(egui::RichText::new(g).font(t.semibold(12.5)).color(t.text)); + egui::Grid::new(format!("meta-{g}")).num_columns(2).spacing([16.0, 3.0]).striped(true).show(ui, |ui| { + for (n, v) in list { + ui.label(egui::RichText::new(n).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap()); + ui.end_row(); + } + }); + } + }); +} + +/// The body of the SmartRules dialog. +fn smart_rules_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::SmartRules { id, name, rules, .. } = dlg else { return }; + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Name"), + ); + crate::widgets::register(ui, "field:smartName", r.rect); + ui.add_space(6.0); + // the folder an album made from a folder view carries is not in the editor, but it counts + let folder = id + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))) + .and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); + // problems, count and albums: cached until the catalog or the rules change (not per frame) + let now = (app.session.clock)(); + let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); + let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; + egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| { + crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); + }); + let n = view.count; + ui.add_space(4.0); + if view.problems.is_empty() { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "{n} photo{} match · updates automatically as photos change", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); + } else { + ui.label(egui::RichText::new(crate::i18n::tr("Fix the marked rules to save this album.")).color(t.caution)); + } +} + +/// The body of the NewSmartAlbum dialog. +fn new_smart_album_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::NewSmartAlbum { name, .. } = dlg else { return }; + if dialog_field(ui, "field:smartAlbumName", name, "Name") { + *confirm = true; + } + let rules = app.session.view_rules(); + // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame + let now = (app.session.clock)(); + let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; + let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { + lightcraft_catalog::rules::set_now(Some(now.clone())); + app.session.catalog.query(&rules, &Default::default()).len() + }); + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label), + ); + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "{n} photo{} now · updates automatically as photos change", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); +} + +/// The body of the CaptureTime dialog. +fn capture_time_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::CaptureTime { mode, time, days, hours, minutes, zone } = dlg else { return }; + let n = app.session.selected().len(); + field(ui, "Change", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { + if crate::widgets::text_button(ui, &format!("captureMode-{i}"), label, mode == m).clicked() { + *mode = m.to_string(); + } + } + }); + let params = match mode.as_str() { + "set" => { + field(ui, "New time", |ui| { + let r = ui.add(egui::TextEdit::singleline(time).hint_text("2026-09-30 14:05:00").desired_width(f32::INFINITY)); + crate::widgets::register(ui, "field:captureTime", r.rect); + }); + json!({"time": time}) + } + "shift" => { + field(ui, "Shift", |ui| { + ui.add(egui::DragValue::new(days).suffix(" d")); + ui.add(egui::DragValue::new(hours).suffix(" h")); + ui.add(egui::DragValue::new(minutes).suffix(" min")); + }); + json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}) + } + _ => { + field(ui, "Hours", |ui| ui.add(egui::DragValue::new(zone).range(-26.0..=26.0).speed(0.25).fixed_decimals(2))); + json!({"hours": zone}) + } + }; + // preview on the active photo (the others move by the same amount) + let active = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| (p.file_name.clone(), p.date().to_string())); + if let Some((name, cur)) = active { + let delta = match mode.as_str() { + "set" => lightcraft_catalog::dates::normalize_iso(time) + .and_then(|t| Some(lightcraft_catalog::dates::iso_seconds(&t)? - lightcraft_catalog::dates::iso_seconds(&cur)?)), + "shift" => params["shift"].as_i64(), + _ => Some((*zone as f64 * 3600.0).round() as i64), + }; + // 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), + ); + } + if n > 1 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("All {n} selected photos move by the same amount.", n = n)).color(t.text_dim)); + } +} + +/// The body of the LabelNames dialog. +fn label_names_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::LabelNames { names, save_as } = dlg else { return }; + names.resize(5, String::new()); + // start from a set + let sets = lightcraft_engine::cmd::manage::label_sets_json(&app.session); + field(ui, "Set", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for set in sets["sets"].as_array().into_iter().flatten() { + let name = set["name"].as_str().unwrap_or_default(); + if crate::widgets::text_button(ui, &format!("labelSet-{name}"), name, false).clicked() { + *names = set["names"].as_array().into_iter().flatten().map(|n| n.as_str().unwrap_or_default().to_string()).collect(); + } + } + }); + for (i, l) in lightcraft_catalog::ColorLabel::ALL.iter().enumerate() { + let colour = format!("{l:?}"); + field(ui, &colour, |ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l)); + let te = crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), + crate::i18n::tr(&colour), + ); + crate::widgets::register(ui, format!("field:labelName-{}", colour.to_lowercase()), te.rect); + }); + } + field(ui, "Save as set", |ui| { + let te = crate::widgets::named( + ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Save as set"), + ); + crate::widgets::register(ui, "field:labelSetName", te.rect); + }); + ui.label( + egui::RichText::new(crate::i18n::tr( + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.", + )) + .color(t.text_dim), + ); +} + +/// The body of the Rename dialog. +fn rename_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Rename { template, start } = dlg else { return }; + let n = app.session.selected().len(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + let template_id = egui::Id::unique("rename-template"); + let tags_open = field(ui, "Template", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = crate::import::tag_field_width(ui); + let r = crate::widgets::named( + ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), + crate::i18n::tr("Template"), + ); + crate::widgets::register(ui, "field:renameTemplate", r.rect); + crate::import::tag_toggle(ui, "renameTemplate") + }); + if tags_open { + crate::import::tag_help(ui, "renameTemplate", template, template_id); + } + crate::import::unknown_tags_warning(ui, template); + field(ui, "Presets", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (label, tpl)) in + [("Name", "{name}"), ("Date–Name", "{date}_{name}"), ("Title–Seq", "{title}-{seq:3}"), ("Custom–Seq", "Photo-{seq:3}")].iter().enumerate() + { + if crate::widgets::text_button(ui, &format!("renamePreset-{i}"), label, template == tpl).clicked() { + *template = tpl.to_string(); + } + } + }); + field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999))); + ui.label( + egui::RichText::new(crate::i18n::tr( + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…", + )) + .color(t.text_dim), + ); + let (preview, total) = rename_preview(app, ui.ctx(), template, *start as usize); + match &preview { + Some(rows) => { + egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { + for pl in rows { + ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); + let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); + ui.label(egui::RichText::new(&pl.to).color(t.text)); + ui.end_row(); + } + }); + } + None => { + 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); + if more > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); + } +} + +/// The body of the RenamePerson dialog. +fn rename_person_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::RenamePerson { from, to, merge } = dlg else { return }; + let submitted = dialog_field(ui, "field:personName", to, "Name"); + let plan = person_rename_plan(&app.session.catalog, from, to); + if !plan.needs_merge { + *merge = false; + } + if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { + *confirm = true; + } + if let Some(why) = plan.block { + ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); + } else { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames {n} face{} across the library. The files are not changed.", + if plan.faces == 1 { "" } else { "s" }, + n = plan.faces + )) + .color(t.text_dim), + ); + } + if plan.needs_merge { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + to = to.trim() + )) + .color(t.caution), + ); + let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); + crate::widgets::register(ui, "check:personMerge", r.rect); + } +} + +/// The body of the RenameKeyword dialog. +fn rename_keyword_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::RenameKeyword { from, to } = dlg else { return }; + let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { + app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() + }); + if dialog_field(ui, "field:keywordName", to, "New name") { + *confirm = true; + } + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.", + if n == 1 { "" } else { "s" }, + from = from, + n = n + )) + .color(t.text_dim), + ); +} + +/// The body of the MergeKeywords dialog. +fn merge_keywords_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::MergeKeywords { from, into } = dlg else { return }; + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))) + .color(t.text_label), + ); + if dialog_field(ui, "field:keywordInto", into, "Keyword") { + *confirm = true; + } + let options: Vec = app + .caches + .suggestions(&app.session.catalog, from, into, 12) + .iter() + .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .cloned() + .collect(); + ui.horizontal_wrapped(|ui| { + for k in options { + if ui.add(egui::Button::new(egui::RichText::new(&k).color(t.text_label)).corner_radius(10.0)).clicked() { + *into = k; + } + } + }); +} + +/// The body of the NewAlbum dialog. +fn new_album_body(app: &mut LightcraftApp, ui: &mut egui::Ui, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::NewAlbum { name, folder, include_selected, .. } = dlg else { return }; + if dialog_field(ui, "field:albumName", name, "Name") { + *confirm = true; + } + // as in Lightroom, the option is only there when something is selected + if !*folder && !app.session.selected().is_empty() { + ui.add_space(6.0); + let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); + crate::widgets::register(ui, "check:albumIncludeSelected", r.rect); + } +} + +/// The body of the PasteSettings dialog. +fn paste_settings_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::PasteSettings { groups } = dlg else { return }; + let n = app.session.selected().len(); + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Paste into {n} photo{} — only settings that were copied are pasted", + if n == 1 { "" } else { "s" }, + n = n + )) + .color(t.text_dim), + ); + group_checklist(ui, "pasteGroup", groups); +} + +/// The body of the ContactSheet dialog. +fn contact_sheet_body(app: &mut LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { + let Dialog::ContactSheet { options } = dlg else { return }; + options.columns = options.columns.clamp(1, 8); + options.rows = options.rows.clamp(1, 10); + let n = app.session.selected().len(); + ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); + field(ui, "Paper", |ui| { + let c = crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { + ui.selectable_value(&mut options.paper, "a4".into(), "A4"); + ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); + }); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Paper")); + }); + ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); + field(ui, "Columns", |ui| { + ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); + }); + field(ui, "Rows", |ui| { + ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); + }); + ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); + ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); + ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); +} + +/// The body of the Export dialog. +fn export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } = dlg else { return }; + let n = app.session.selection.ids.len().max(1); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + export_preset_row(app, ui, opts, full_size, resize, limit_kb); + ui.add_space(4.0); + export_format_rows(ui, t, opts); + export_color_rows(ui, t, opts); + export_quality_rows(ui, opts, limit_kb); + if opts.format.is_rendered() { + export_size(ui, full_size, resize, &mut opts.ppi); + export_output_rows(app, ui, opts, *full_size, *resize); + } + ui.add_space(4.0); + export_naming_rows(ui, opts); + export_destination_rows(app, ui, t, opts, dir); + export_save_preset_row(app, ui, opts, *full_size, resize, *limit_kb, preset_name); +} + +/// Export dialog: load a built-in or saved set of options. +fn export_preset_row( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + opts: &mut ExportOptions, + full_size: &mut bool, + resize: &mut Resize, + limit_kb: &mut u32, +) { + // Preset: load a built-in or saved set of options into the dialog + field(ui, "Preset", |ui| { + let mut chosen = None; + let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { + let mut builtin = true; + for (p, b) in app.session.all_export_presets() { + if builtin && !b { + ui.separator(); + } + builtin = b; + if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { + chosen = Some(p.name); + } + } + }); + crate::widgets::name_combo(&c.response, crate::i18n::tr("Preset")); + crate::widgets::register(ui, "combo:exportPreset", c.response.rect); + if let Some(name) = chosen + && let Ok(params) = app.session.export_params(&json!({"preset": name})) + { + let o = lightcraft_engine::export::ExportOptions::from_json(¶ms); + *full_size = o.resize.is_none(); + *resize = o.resize.unwrap_or_default(); + *limit_kb = o.limit_kb.unwrap_or(0); + *opts = o; + } + }); +} + +/// Export dialog: the format, its variants and the bit depth. +fn export_format_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; + let before = opts.format; + // "Rendered DNG" is a DNG option (below), not a format of its own in this list + let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; + choices( + ui, + "Format", + "exportFormat", + &[ + (F::Jpeg, "JPEG"), + (F::Png, "PNG"), + (F::Tiff, "TIFF"), + (F::Psd, "PSD"), + (F::Webp, "WebP"), + (F::Avif, "AVIF"), + (F::Jxl, "JPEG XL"), + (F::Dng, "DNG"), + (F::Original, "Original files"), + ], + &mut shown, + ); + if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { + opts.format = shown; + } + if matches!(opts.format, F::Dng | F::RenderedDng) { + choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); + } + if !opts.format.is_rendered() { + let note = if opts.format == F::Dng { + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." + } else { + "The original files, unchanged, each with an XMP sidecar holding its edits." + }; + ui.label(egui::RichText::new(note).color(t.text_dim)); + } + if opts.format != before { + // each format starts at its own default depth (TIFF 16-bit, others 8-bit) + opts.bit_depth = None; + } + let rendered = opts.format.is_rendered(); + let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); + if rendered && depths.len() > 1 { + let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); + choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); + opts.bit_depth = Some(bd); + } +} + +/// Export dialog: HDR output and the color space it implies (or the choice of one). +fn export_color_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; + let rendered = opts.format.is_rendered(); + if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { + let tip = crate::i18n::tr( + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float", + ); + ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); + if opts.hdr && !opts.hdr_output() { + ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); + } + if opts.hdr && opts.format == F::Avif { + use lightcraft_engine::export::HdrCurve as H; + choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); + } + } + if !rendered { + } else if opts.format == F::Avif && opts.hdr { + let label = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { + crate::i18n::tr("Color space: Rec. 2020 HLG (HDR AVIF)") + } else { + crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)") + }; + ui.label(egui::RichText::new(label).color(t.text_dim)); + } else if opts.format == F::Png && opts.hdr { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); + } else if opts.format == F::Jxl && opts.hdr { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); + } else if opts.format == F::Avif { + ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); + } else { + use lightcraft_engine::export::OutputSpace as C; + choices( + ui, + "Color space", + "exportColorSpace", + &[(C::Srgb, "sRGB"), (C::DisplayP3, "P3"), (C::AdobeRgb, "Adobe RGB"), (C::ProPhoto, "ProPhoto"), (C::Rec2020, "Rec.2020")], + &mut opts.color_space, + ); + if opts.effective_space() != opts.color_space { + let note = "Lossy JPEG XL writes Adobe RGB and ProPhoto as Rec. 2020; choose Lossless to keep them."; + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + } + } +} + +/// Export dialog: quality and the other options of a lossy or JPEG XL file. +fn export_quality_rows(ui: &mut egui::Ui, opts: &mut ExportOptions, limit_kb: &mut u32) { + use lightcraft_engine::export::ExportFormat as F; + if opts.format == F::Jxl { + let tip = "Mathematically lossless: every pixel is kept exactly (larger files)"; + ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip)); + } + if matches!(opts.format, F::Jpeg | F::Avif) || (opts.format == F::Jxl && !opts.jxl_lossless) { + let mut q = opts.quality as f64; + if num(ui, &QUALITY, &mut q) { + opts.quality = q as u8; + } + } + if opts.format == F::Jxl { + use lightcraft_engine::export::JxlEffort as E; + choices(ui, "Effort", "exportJxlEffort", &[(E::Fast, "Fast"), (E::Normal, "Normal")], &mut opts.jxl_effort); + let tip = "Shows a coarse version first and refines it as the file loads"; + ui.checkbox(&mut opts.jxl_progressive, crate::i18n::tr("Progressive")).on_hover_text(crate::i18n::tr(tip)); + } + if opts.format == F::Jpeg { + let mut k = *limit_kb as f64; + if num(ui, &LIMIT_KB, &mut k) { + *limit_kb = k as u32; + } + } +} + +/// Export dialog: sharpening, metadata and the watermark of a rendered photo. +fn export_output_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut ExportOptions, full_size: bool, resize: Resize) { + use lightcraft_engine::export::{MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; + choices( + ui, + "Sharpen", + "exportSharpen", + &[(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte"), (S::Glossy, "Glossy")], + &mut opts.sharpen, + ); + if opts.sharpen != S::None { + choices(ui, "Amount", "exportSharpenAmount", &[(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")], &mut opts.sharpen_amount); + } + choices( + ui, + "Metadata", + "exportMetadata", + &[(M::All, "All"), (M::AllExceptCamera, "No camera"), (M::Copyright, "Copyright"), (M::None, "None")], + &mut opts.metadata, + ); + if !matches!(opts.metadata, M::None | M::Copyright) { + ui.checkbox(&mut opts.remove_location, crate::i18n::tr("Remove location info")); + } + export_watermark(app, ui, opts, full_size, resize); +} + +/// Export dialog: the watermark switch, its preview and its settings. +fn export_watermark(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut ExportOptions, full_size: bool, resize: Resize) { + let mut wm_on = opts.watermark.is_some(); + if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { + opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); + } + let wm_shown = opts.watermark.clone(); + if let Some(wm) = &wm_shown { + watermark_preview(app, ui, wm, (!full_size).then_some(resize)); + } + if let Some(wm) = &mut opts.watermark { + // text, or a graphic (a logo with transparency) + let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::unique("wm-graphic"))).unwrap_or(false); + let before = graphic; + choices(ui, "Style", "exportWmStyle", &[(false, "Text"), (true, "Graphic")], &mut graphic); + if graphic != before { + ui.data_mut(|d| d.insert_temp(egui::Id::unique("wm-graphic"), graphic)); + if !graphic { + wm.image.clear(); + } + } + if graphic { + field(ui, "Graphic", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_files.is_some() + && crate::widgets::text_button(ui, "exportWmChoose", crate::i18n::tr("Choose…"), false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + wm.image = f; + } + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), + crate::i18n::tr("Image"), + ); + }); + }); + let mut width = wm.image_width as f64 * 100.0; + if num(ui, &WM_IMAGE_WIDTH, &mut width) { + wm.image_width = (width / 100.0) as f32; + } + } else { + choices( + ui, + app.ui.language.tr("Text direction"), + "exportWmOrientation", + &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], + &mut wm.vertical, + ); + field(ui, "Text", |ui| { + crate::widgets::named( + ui.add( + egui::TextEdit::multiline(&mut wm.text) + .desired_rows(2) + .hint_text(crate::i18n::tr("© Your Name")) + .desired_width(f32::INFINITY), + ), + crate::i18n::tr("Text"), + ) + }); + } + choices(ui, "Position", "exportWmAnchor", &WM_POSITIONS, &mut wm.anchor); + choices(ui, "Rotate", "exportWmRotation", &[(0u16, "0°"), (90, "90°"), (180, "180°"), (270, "270°")], &mut wm.rotation); + let mut size = wm.size as f64 * 100.0; + if !graphic && num(ui, &WM_SIZE, &mut size) { + wm.size = (size / 100.0) as f32; + } + let mut op = wm.opacity as f64 * 100.0; + if num(ui, &WM_OPACITY, &mut op) { + wm.opacity = (op / 100.0) as f32; + } + // like Lightroom Classic: a margin from the side edges and one from the top and bottom + // edges, each unused (greyed) when the position is centred along that axis + use lightcraft_engine::export::Anchor as P; + let across = !matches!(wm.anchor, P::Top | P::Center | P::Bottom); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_X, wm.inset_x as f64 * 100.0, across, None).value { + wm.inset_x = (v / 100.0) as f32; + } + let down = !matches!(wm.anchor, P::Left | P::Center | P::Right); + if let Some(v) = crate::widgets::slider(ui, &WM_INSET_Y, wm.inset_y as f64 * 100.0, down, None).value { + wm.inset_y = (v / 100.0) as f32; + } + if !graphic { + ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); + } + } + if opts.watermark != wm_shown { + // the preview above was drawn with the old settings + ui.ctx().request_repaint(); + } +} + +/// Export dialog: compression and the file name. +fn export_naming_rows(ui: &mut egui::Ui, opts: &mut ExportOptions) { + use lightcraft_engine::export::ExportFormat as F; + if opts.format == F::Tiff { + use lightcraft_engine::export::TiffCompression as Z; + choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); + } + if opts.format == F::Dng { + use lightcraft_engine::export::DngCompression as Z; + choices( + ui, + "Compression", + "exportDngCompression", + &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], + &mut opts.dng_compression, + ); + } + let naming_id = egui::Id::unique("export-naming"); + let tags_open = field(ui, "File name", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = crate::import::tag_field_width(ui); + crate::widgets::named( + ui.add( + egui::TextEdit::singleline(&mut opts.naming) + .id(naming_id) + .hint_text("{name}-{seq} · {date} · {title} · {folder}") + .desired_width(w), + ), + crate::i18n::tr("File name"), + ); + crate::import::tag_toggle(ui, "exportNaming") + }); + if tags_open { + crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id); + } + crate::import::unknown_tags_warning(ui, &opts.naming); + if opts.naming.contains("{seq") { + let mut v = opts.start_number as f64; + if num(ui, &START_NUMBER, &mut v) { + opts.start_number = v as u32; + } + } +} + +/// Export dialog: where the files go and what happens when one exists. +fn export_destination_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions, dir: &mut String) { + // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on + // the web, where originals live in the browser's storage, not in folders) + use lightcraft_engine::export::DestinationMode as D; + if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder { + choices( + ui, + "Export to", + "exportDestinationMode", + &[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")], + &mut opts.destination_mode, + ); + } + if opts.destination_mode == D::SpecificFolder { + field(ui, "Folder", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() + && let Some(pick) = app.services.pick_folder.as_mut() + && let Some(d) = pick() + { + *dir = d; + } + crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); + }); + }); + } + field(ui, "Subfolder", |ui| { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), + crate::i18n::tr("Subfolder"), + ) + }); + use lightcraft_engine::export::Conflict as K; + choices(ui, "If file exists", "exportConflict", &[(K::Unique, "Add number"), (K::Overwrite, "Overwrite"), (K::Skip, "Skip")], &mut opts.conflict); + export_apple_photos_rows(ui, t, opts); +} + +/// Export dialog, after export: Apple Photos imports the files written (macOS only). +#[cfg(target_os = "macos")] +fn export_apple_photos_rows(ui: &mut egui::Ui, t: &Tokens, opts: &mut ExportOptions) { + let r = ui.checkbox(&mut opts.add_to_photos, crate::i18n::tr("Add to Apple Photos")); + crate::widgets::register(ui, "exportAddToPhotos", r.rect); + if opts.add_to_photos { + field(ui, "Album", |ui| { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), + crate::i18n::tr("Album"), + ) + }); + ui.label( + egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")).size(11.0).color(t.text_dim), + ); + } +} + +/// Nowhere to add them: a setting carried over from a Mac is dropped, not refused. +#[cfg(not(target_os = "macos"))] +fn export_apple_photos_rows(_ui: &mut egui::Ui, _t: &Tokens, opts: &mut ExportOptions) { + opts.add_to_photos = false; +} + +/// Export dialog: save the options as a preset. +fn export_save_preset_row( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + opts: &ExportOptions, + full_size: bool, + resize: &Resize, + limit_kb: u32, + preset_name: &mut String, +) { + ui.add_space(4.0); + field(ui, "Save preset", |ui| { + trailing_button_row(ui, |ui| { + let named = !preset_name.trim().is_empty(); + if crate::widgets::text_button(ui, "exportSavePreset", crate::i18n::tr("Save"), false).clicked() && named { + let params = export_dialog_params(opts, full_size, resize, limit_kb); + match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) { + Ok(_) => { + app.toast(ui.ctx(), crate::i18n::tr_format!("Saved export preset “{}”", preset_name.trim())); + preset_name.clear(); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + crate::widgets::named( + ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), + crate::i18n::tr("Preset name"), + ); + }); + }); +} + +/// The body of the KeywordTag dialog. +fn keyword_tag_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + .. + } = dlg + else { + return; + }; + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); + // several fields: the name takes the focus when the dialog opens, not after + let r = crate::text_field::TextField::singleline("field:keywordTagName", name) + .hint(crate::i18n::tr("Keyword Name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if ui.is_enabled() && just_opened(ui, "keyword-tag") { + r.response.request_focus(); + } + if r.ending == Some(crate::text_field::Ending::Return) { + *confirm = true; + } + ui.add_space(4.0); + ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); + // Return in either field creates (or saves) the keyword + let r = crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) + .hint(crate::i18n::tr("Separated by commas")) + .width(f32::INFINITY) + .show(ui); + if r.ending == Some(crate::text_field::Ending::Return) { + *confirm = true; + } + ui.add_space(6.0); + if editing.is_none() { + if let Some(p) = parent { + let r = ui.checkbox(inside, crate::i18n::tr_format!("Put inside “{parent}”", parent = p.replace('|', " › "))); + crate::widgets::register(ui, "check:keywordInside", r.rect); + } + let n = app.session.selection.ids.len(); + let r = ui.add_enabled(n > 0, egui::Checkbox::new(add_to_selected, crate::i18n::tr("Add to Selected Photos"))); + crate::widgets::register(ui, "check:keywordAddToSelected", r.rect); + ui.add_space(4.0); + } + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Tag Options")).color(t.text_dim)); + for (on, label, id) in [ + (include_on_export, "Include on Export", "check:keywordIncludeOnExport"), + (export_containing, "Export Containing Keywords", "check:keywordExportContaining"), + (export_synonyms, "Export Synonyms", "check:keywordExportSynonyms"), + (person, "Person", "check:keywordPerson"), + ] { + let r = ui.checkbox(on, crate::i18n::tr(label)); + crate::widgets::register(ui, id, r.rect); + } +} + +/// The body of the KeywordSet dialog. +fn keyword_set_body(ui: &mut egui::Ui, t: &Tokens, confirm: &mut bool, dlg: &mut Dialog) { + let Dialog::KeywordSet { replaces, name, slots, as_new } = dlg else { return }; + ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim)); + let r = crate::text_field::TextField::singleline("field:keywordSetName", name) + .hint(crate::i18n::tr("Set name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if ui.is_enabled() && just_opened(ui, "keyword-set") { + r.response.request_focus(); + } + let mut returned = r.ending == Some(crate::text_field::Ending::Return); + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr("Keywords (⌥1–⌥9)")).color(t.text_dim)); + slots.resize(9, String::new()); + // equal columns of a set width (the window is still finding its own) + let w = 130.0; + egui::Grid::new("keyword-set-slots").num_columns(3).min_col_width(w).spacing([6.0, 4.0]).show(ui, |ui| { + for (i, slot) in slots.iter_mut().enumerate() { + let r = crate::text_field::TextField::singleline(&format!("field:keywordSetSlot:{}", i + 1), slot) + .hint(format!("⌥{}", i + 1)) + .width(w) + .show(ui); + returned |= r.ending == Some(crate::text_field::Ending::Return); + if i % 3 == 2 { + ui.end_row(); + } + } + }); + if replaces.is_some() { + ui.add_space(6.0); + let r = ui.checkbox(as_new, crate::i18n::tr("Save as a new set")); + crate::widgets::register(ui, "check:keywordSetAsNew", r.rect); + } + // Return in any field saves the set + if returned { + *confirm = true; + } +} + +/// The body of the MoveKeyword dialog. +fn move_keyword_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::MoveKeyword { keyword, parent } = dlg else { return }; + let name = keyword.rsplit('|').next().unwrap_or(keyword); + match parent { + Some(p) => { + ui.label(crate::i18n::tr_format!("“{parent}” has a “{name}” already. Merge the two?", parent = p.replace('|', " › "), name = name)) + } + None => ui.label(crate::i18n::tr_format!("There is a “{name}” at the top level already. Merge the two?", name = name)), + }; + ui.label(egui::RichText::new(crate::i18n::tr("Their photos and the keywords below them come together under one keyword.")).color(t.text_dim)); +} + +/// The body of the DeleteKeyword dialog. +fn delete_keyword_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::DeleteKeyword { keyword, count } = dlg else { return }; + let name = keyword.replace('|', " › "); + ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); + ui.label( + egui::RichText::new(if *count == 0 { + crate::i18n::tr("No photo has it; the keywords below it go too.").to_string() + } else { + crate::i18n::tr_format!( + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.", + if *count == 1 { "" } else { "s" }, + count = count + ) + }) + .color(t.text_dim), + ); +} + +/// The body of the DeleteEffects dialog. +fn delete_effects_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::DeleteEffects { names } = dlg else { return }; + let what = match names.as_slice() { + [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), + _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), + }; + ui.label(what); + ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); +} + +/// The body of the MovePhotos dialog. +fn move_photos_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::MovePhotos { ids, name, dont_ask, .. } = dlg else { return }; + let n = ids.len(); + ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); + ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); + ui.add_space(4.0); + let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); + crate::widgets::register(ui, "check:moveDontAsk", c.rect); + c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); +} + +/// The body of the LightroomCatalog dialog. +fn lightroom_catalog_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::LightroomCatalog { folder, catalog } = dlg else { return }; + 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)); + // (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)); +} + +/// The body of the RemoveFolder dialog. +fn remove_folder_body(ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::RemoveFolder { name, count, path, disk } = dlg else { return }; + ui.label(crate::i18n::tr_format!( + "Remove “{name}” and its {count} photo{} from the library?", + if *count == 1 { "" } else { "s" }, + name = name, + count = count + )); + ui.label(egui::RichText::new(path.as_str()).color(t.text_dim)); + let scope = if *disk { "All the photos imported from this disk are included." } else { "That includes the photos in the folders inside it." }; + ui.label(egui::RichText::new(crate::i18n::tr(scope)).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("They move to Recently Deleted and can be restored; no file on disk is touched.")).color(t.text_dim), + ); +} + +/// The body of the About dialog. +fn about_body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, dlg: &mut Dialog) { + let Dialog::About = dlg else { return }; + ui.set_min_width(680.0); + let tab_id = egui::Id::unique("about_tab"); + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (id, label)) in ABOUT_TABS.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("aboutTab-{id}"), label, usize::from(tab) == i).clicked() { + tab = u8::try_from(i).unwrap_or(0); + } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => crate::credits::contributors_ui(app, ui), + 2 => crate::credits::models_ui(ui), + _ => { + ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); + ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); + ui.label(crate::i18n::tr_format!( + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", + crate::theme::font_credits(app.chinese_font.is_some()) + )); + ui.add_space(10.0); + let r = crate::button::Button::new("Join the ArtCraft Discord") + .primary() + .large() + .min_width(260.0) + .widget("button:aboutDiscord") + .tooltip(crate::links::DISCORD) + .show(ui); + if r.clicked() { + let _ = crate::links::open(app, crate::links::DISCORD); + } + ui.add_space(6.0); + for (label, url) in [ + ("LightCraft website", crate::links::APP_PAGE), + ("Source code on GitHub", crate::links::GITHUB), + ("ArtCraft — more creative apps", crate::links::WEBSITE), + ] { + let r = ui.link(crate::i18n::tr(label)).on_hover_text(url); + if r.clicked() { + let _ = crate::links::open(app, url); + } + } + } + } +} + +/// The body of the open dialog: what each kind of dialog shows between its title and its buttons. `confirm` is set +/// by a dialog that is confirmed from its body (a text field's Return). +fn dialog_content(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, body_max: f32, confirm: &mut bool, dlg: &mut Dialog) { + match dlg { + Dialog::AutoStack { .. } => auto_stack_body(app, ui, t, dlg), + Dialog::Cull { .. } => cull_body(app, ui, t, dlg), + Dialog::WhatsNew => whats_new_body(ui, t, dlg), + Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, t, info, accepted), + Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, t, info, accepted), + Dialog::LightroomResult { .. } => lightroom_result_body(ui, dlg), + Dialog::SystemInfo { .. } => system_info_body(ui, t, dlg), + Dialog::AllMetadata { .. } => all_metadata_body(ui, t, dlg), + Dialog::SmartRules { .. } => smart_rules_body(app, ui, t, dlg), + Dialog::NewSmartAlbum { .. } => new_smart_album_body(app, ui, t, confirm, dlg), + Dialog::CaptureTime { .. } => capture_time_body(app, ui, t, dlg), + Dialog::LabelNames { .. } => label_names_body(app, ui, t, dlg), + Dialog::Rename { .. } => rename_body(app, ui, t, dlg), + Dialog::RenamePerson { .. } => rename_person_body(app, ui, t, confirm, dlg), + Dialog::RenameKeyword { .. } => rename_keyword_body(app, ui, t, confirm, dlg), + Dialog::MergeKeywords { .. } => merge_keywords_body(app, ui, t, confirm, dlg), + Dialog::TextPrompt { value, hint, .. } => { + if dialog_field(ui, "field:prompt", value, hint) { + *confirm = true; + } + } + Dialog::NewAlbum { .. } => new_album_body(app, ui, confirm, dlg), + Dialog::RenameAlbum { name, .. } => { + if dialog_field(ui, "field:albumName", name, "Name") { + *confirm = true; + } + } + Dialog::CreatePreset { name, group, groups } => { + crate::widgets::named( + ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), + crate::i18n::tr("Preset name"), + ); + crate::widgets::named( + ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), + crate::i18n::tr("Group"), + ); + ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); + group_checklist(ui, "presetInclude", groups); + } + Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), + Dialog::PasteSettings { .. } => paste_settings_body(app, ui, t, dlg), + Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), + Dialog::ContactSheet { .. } => contact_sheet_body(app, ui, dlg), + Dialog::Export { .. } => export_body(app, ui, t, dlg), + Dialog::Merge { opts } => crate::merge::body(app, ui, opts), + Dialog::Import { opts } => crate::import::body(app, ui, opts), + // (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 { .. } => keyword_tag_body(app, ui, t, confirm, dlg), + Dialog::KeywordSet { .. } => keyword_set_body(ui, t, confirm, dlg), + Dialog::MoveKeyword { .. } => move_keyword_body(ui, t, dlg), + Dialog::DeleteKeyword { .. } => delete_keyword_body(ui, t, dlg), + Dialog::SaveEffect { name, group, fields, amount, brush } => { + if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { + *confirm = true; + } + } + Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { + crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) + } + Dialog::DeleteEffects { .. } => delete_effects_body(ui, t, dlg), + Dialog::ConfirmDelete { count } => { + let what = + if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; + ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); + ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); + } + Dialog::MovePhotos { .. } => move_photos_body(ui, t, dlg), + d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), + Dialog::LightroomCatalog { .. } => lightroom_catalog_body(app, ui, t, dlg), + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::body(app, ui, d), + Dialog::RemoveFolder { .. } => remove_folder_body(ui, t, dlg), + Dialog::About => about_body(app, ui, t, dlg), + Dialog::Shortcuts => crate::panels::keymap::body(app, ui, t), + Dialog::ViewOptions => crate::panels::settings::view_options(app, ui, t), + Dialog::Print => crate::print_view::body(app, ui, t), + } +} diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 196018e55..6b8998c33 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -1,6 +1,6 @@ use super::cull::cull_params; use super::export::export_dialog_params; -use super::*; +use super::{Dialog, LightcraftApp, json}; /// Runs a command that creates an album and asks the Albums tree to open the folders down to it. fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) -> Result { diff --git a/crates/ui-egui/src/dialogs/cull.rs b/crates/ui-egui/src/dialogs/cull.rs index 2c3d6497a..a43276a64 100644 --- a/crates/ui-egui/src/dialogs/cull.rs +++ b/crates/ui-egui/src/dialogs/cull.rs @@ -1,4 +1,4 @@ -use super::*; +use super::json; /// 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` diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs index 56292605c..ce9cffe77 100644 --- a/crates/ui-egui/src/dialogs/export.rs +++ b/crates/ui-egui/src/dialogs/export.rs @@ -1,5 +1,5 @@ use super::frame::{field, num}; -use super::*; +use super::{ControlSpec, LightcraftApp, Section, SettingsGroup, Tokens, Track}; /// The Export dialog's watermark preview: the first exported photo, as wide as the dialog whatever /// its shape, with the watermark drawn on it as the file will have it at size `resize` (`None` = diff --git a/crates/ui-egui/src/dialogs/frame.rs b/crates/ui-egui/src/dialogs/frame.rs index 3d8eb4ef8..007c733cb 100644 --- a/crates/ui-egui/src/dialogs/frame.rs +++ b/crates/ui-egui/src/dialogs/frame.rs @@ -1,5 +1,5 @@ use super::export::LABEL_W; -use super::*; +use super::{BACKDROP_SHOWN, ControlSpec, Dialog, LightcraftApp, Tokens}; /// 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 diff --git a/crates/ui-egui/src/dialogs/import/body.rs b/crates/ui-egui/src/dialogs/import/body.rs index 9ba470660..8f82ae51d 100644 --- a/crates/ui-egui/src/dialogs/import/body.rs +++ b/crates/ui-egui/src/dialogs/import/body.rs @@ -1,5 +1,5 @@ use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; -use super::*; +use super::{Align2, Color32, ImportDialog, LightcraftApp, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, Vec2, pos2, register, vec2}; /// The review dialog's first size (points). It can be resized; the photo grid takes the height. pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs index ee237fd5a..67b1fa176 100644 --- a/crates/ui-egui/src/dialogs/import/scan.rs +++ b/crates/ui-egui/src/dialogs/import/scan.rs @@ -1,5 +1,5 @@ use super::task::ImportTask; -use super::*; +use super::{ImportDialog, LightcraftApp, Ordering, ScanInput, ScanOutput, ScanProgress, Value, json, scan_with}; /// A folder scan running on a worker thread (a network share can take minutes to read; the /// window must keep answering meanwhile). @@ -118,12 +118,9 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> let mut v: Vec = std::fs::read_dir(&root) .map(|rd| { rd.flatten() + .filter(|e| !lightcraft_engine::walk::is_hidden_entry(e)) .map(|e| e.path()) - .filter(|f| { - f.is_file() - && lightcraft_engine::import::is_supported(f) - && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) - }) + .filter(|f| f.is_file() && lightcraft_engine::import::is_supported(f)) .map(|f| f.to_string_lossy().to_string()) .collect() }) diff --git a/crates/ui-egui/src/dialogs/import/tags.rs b/crates/ui-egui/src/dialogs/import/tags.rs index 770bb5b30..1ae10ca02 100644 --- a/crates/ui-egui/src/dialogs/import/tags.rs +++ b/crates/ui-egui/src/dialogs/import/tags.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Tokens, register}; /// Reserve the actual translated tag button width beside a template field. pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs index bcef55b11..e1f506d5b 100644 --- a/crates/ui-egui/src/dialogs/import/task.rs +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -1,5 +1,5 @@ use super::scan::{busy_synchronizing, running}; -use super::*; +use super::{ImportDialog, ImportUprightMode, ImportWarning, ImportWarningCode, LightcraftApp, Ordering, Value, json}; /// Files per batch in the browser build (each batch joins the catalog as it is ready, so the /// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`]. @@ -422,33 +422,24 @@ fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { ) } +/// The name of the import's one undo step. +fn undo_label(task: &ImportTask) -> String { + if task.imported == 0 && task.restored > 0 { + crate::i18n::tr_format!("Restore {} Photo{}", task.restored, crate::i18n::plural_s(task.restored)) + } else if task.restored > 0 { + crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, crate::i18n::plural_s(task.imported), task.restored) + } else { + crate::i18n::tr_format!("Add {} Photo{}", task.imported, crate::i18n::plural_s(task.imported)) + } +} + /// The import is done (or cancelled): one undo step, show and select the first photo (unless the /// user chose another meanwhile), say what happened. fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { let steps = app.session.undo.len().saturating_sub(task.undo0); - let plural = |n: usize| if n == 1 { "" } else { "s" }; - let label = if task.imported == 0 && task.restored > 0 { - crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) - } else if task.restored > 0 { - crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) - } else { - crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }) - }; - app.session.merge_undo(steps, &label); - if task.auto { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.imported > 0 { - app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" })); - } - return; - } - if task.browse { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.failed > 0 { - app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); - } + app.session.merge_undo(steps, &undo_label(&task)); + if task.auto || task.browse { + quiet_finish(app, ctx, &task); return; } if let Some(f) = task.first.filter(|_| !task.moved_on) { @@ -459,7 +450,6 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } let _ = app.run("library.select", json!({"ids": [f]})); } - let plural = |n: usize| if n == 1 { "" } else { "s" }; // nothing new, only files the library already has: show where they are (Recently Deleted is // easy to miss, and the side panel that lists it starts collapsed) if task.imported == 0 @@ -471,12 +461,35 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { { return; } - let mut msg = if task.cancelled { - crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) - } else if task.imported == 0 && task.restored > 0 { - crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) + let mut msg = import_summary(app, &task); + if task.warnings.is_empty() { + app.toast(ctx, msg); } else { - crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) + msg.push_str("\n\n"); + msg.push_str(&xmp_warnings_text(&task.warnings)); + app.toast_for(ctx, msg, 8.0); + } +} + +/// The end of an automatic import or of one that only browses: a toast only when there is something to warn about. +fn quiet_finish(app: &mut LightcraftApp, ctx: &egui::Context, task: &ImportTask) { + if !task.warnings.is_empty() { + app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); + } else if task.auto && task.imported > 0 { + app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, crate::i18n::plural_s(task.imported))); + } else if !task.auto && task.failed > 0 { + app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, crate::i18n::plural_s(task.failed))); + } +} + +/// What an import did, in a sentence. +fn import_summary(app: &LightcraftApp, task: &ImportTask) -> String { + let mut msg = if task.cancelled { + crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, crate::i18n::plural_s(task.imported)) + } else if task.imported == 0 && task.restored > 0 { + crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, crate::i18n::plural_s(task.restored)) + } else { + crate::i18n::tr_format!("Imported {} photo{}", task.imported, crate::i18n::plural_s(task.imported)) }; if task.restored > 0 && (task.imported > 0 || task.cancelled) { msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); @@ -484,11 +497,11 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { if task.params["mode"] == "move" { msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved)); if task.kept > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept))); + msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, crate::i18n::plural_s(task.kept))); } } if task.duplicates > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, crate::i18n::plural_s(task.duplicates))); let deleted = deleted_of(app, &task.existing).len(); if deleted > 0 { msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); @@ -497,13 +510,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { if task.failed > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); } - if task.warnings.is_empty() { - app.toast(ctx, msg); - } else { - msg.push_str("\n\n"); - msg.push_str(&xmp_warnings_text(&task.warnings)); - app.toast_for(ctx, msg, 8.0); - } + msg } /// The photos among `ids` that are in Recently Deleted (each once). diff --git a/crates/ui-egui/src/dialogs/print_view.rs b/crates/ui-egui/src/dialogs/print_view.rs index 82a2b564d..c9af1d776 100644 --- a/crates/ui-egui/src/dialogs/print_view.rs +++ b/crates/ui-egui/src/dialogs/print_view.rs @@ -196,13 +196,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { app.ui.print = pu; } -fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { +/// The print templates: save the settings as one, import, and the saved ones to apply, export and delete. +fn templates_section(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi) { let ctx = ui.ctx().clone(); - let l = &st.layout; - let unit = st.guides.units; - ui.spacing_mut().item_spacing.y = 5.0; - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); - heading(ui, t, "Templates"); ui.horizontal(|ui| { crate::text_field::TextField::singleline("field:printTemplateName", &mut pu.template_name) @@ -246,17 +242,13 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, }); }); } +} - heading(ui, t, "Layout Style"); - let styles = - [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; - ui.horizontal_wrapped(|ui| { - if let Some(s) = choice(ui, "printStyle", &styles, l.style) { - patch(app, &ctx, json!({"layout": {"style": s}})); - pu.page = 0; - } - }); - +/// The image settings: zoom, rotation, border and stroke. +fn image_section(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, st: &PrintSettings) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; heading(ui, t, "Image Settings"); let im = &st.image; if let Some(v) = check(ui, "printZoomFill", "Zoom to Fill Frame", im.zoom_to_fill) { @@ -292,6 +284,28 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, patch(app, &ctx, json!({"image": {"strokeColor": c}})); } }); +} + +fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; + ui.spacing_mut().item_spacing.y = 5.0; + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); + + templates_section(app, ui, t, pu); + + heading(ui, t, "Layout Style"); + let styles = + [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; + ui.horizontal_wrapped(|ui| { + if let Some(s) = choice(ui, "printStyle", &styles, l.style) { + patch(app, &ctx, json!({"layout": {"style": s}})); + pu.page = 0; + } + }); + + image_section(app, ui, t, st); heading(ui, t, "Layout"); match l.style { diff --git a/crates/ui-egui/src/dialogs/rename.rs b/crates/ui-egui/src/dialogs/rename.rs index f178ba6eb..8632daf41 100644 --- a/crates/ui-egui/src/dialogs/rename.rs +++ b/crates/ui-egui/src/dialogs/rename.rs @@ -1,4 +1,4 @@ -use super::*; +use super::LightcraftApp; /// Rows the Rename dialog previews. pub(super) const RENAME_PREVIEW_ROWS: usize = 6; diff --git a/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs b/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs index de5ccc2ef..8506a2826 100644 --- a/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs +++ b/crates/ui-egui/src/dialogs/rules_editor/rules_move.rs @@ -154,7 +154,7 @@ pub fn move_node(rs: &mut RuleSet, from: &[usize], dest: &[usize]) -> Option chain.len() && dest.starts_with(&chain)) && list_at(&next, &chain).is_some_and(|l| l.rules.is_empty()) { + 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; diff --git a/crates/ui-egui/src/dialogs/sam_model.rs b/crates/ui-egui/src/dialogs/sam_model.rs index fa578440b..fafa91ceb 100644 --- a/crates/ui-egui/src/dialogs/sam_model.rs +++ b/crates/ui-egui/src/dialogs/sam_model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, Tokens, json}; /// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is /// all the dialog can offer. diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs index 51159df6b..f714a97e6 100644 --- a/crates/ui-egui/src/dialogs/settings/appearance.rs +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -1,5 +1,5 @@ use super::widgets::{choices, heading}; -use super::*; +use super::{RichText, Tokens, register}; /// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and /// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in @@ -17,9 +17,15 @@ pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::Ap let mut cards = |ui: &mut egui::Ui, width: f32| { ui.spacing_mut().item_spacing.x = 12.0; let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); - theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); + theme_card( + ui, + t, + ThemeCard { title: "Light Theme", active: light_active, options: &light, width }, + &mut s.light_theme, + &mut s.favorite_themes, + ); let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); - theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); + theme_card(ui, t, ThemeCard { title: "Dark Theme", active: !light_active, options: &dark, width }, &mut s.dark_theme, &mut s.favorite_themes); }; if available < 440.0 { ui.vertical(|ui| cards(ui, available)); @@ -29,18 +35,19 @@ pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::Ap ui.add_space(2.0); } +/// What a theme family's card shows. +struct ThemeCard<'a, V> { + title: &'a str, + /// the family in use + active: bool, + options: &'a [(V, crate::theme::ThemeKind)], + width: f32, +} + /// One family's card: its title (and "Active" when it is the one shown), a preview of the /// selected theme and a radio button per theme. -fn theme_card( - ui: &mut egui::Ui, - t: &Tokens, - title: &str, - active: bool, - options: &[(V, crate::theme::ThemeKind)], - value: &mut V, - favorites: &mut Vec, - width: f32, -) { +fn theme_card(ui: &mut egui::Ui, t: &Tokens, card: ThemeCard, value: &mut V, favorites: &mut Vec) { + let ThemeCard { title, active, options, width } = card; egui::Frame::new() .fill(t.inset) .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) diff --git a/crates/ui-egui/src/dialogs/settings/display.rs b/crates/ui-egui/src/dialogs/settings/display.rs index a54b05ced..a5b1974d2 100644 --- a/crates/ui-egui/src/dialogs/settings/display.rs +++ b/crates/ui-egui/src/dialogs/settings/display.rs @@ -1,6 +1,6 @@ use super::appearance::appearance; use super::widgets::{check, choices, heading, hint, path_label, row}; -use super::*; +use super::{GridBadges, LightcraftApp, RichText, Tokens, Value, json, register}; // ---------------------------------------------------------------------------------- Interface diff --git a/crates/ui-egui/src/dialogs/settings/general.rs b/crates/ui-egui/src/dialogs/settings/general.rs index 21b94f150..85866b5fa 100644 --- a/crates/ui-egui/src/dialogs/settings/general.rs +++ b/crates/ui-egui/src/dialogs/settings/general.rs @@ -1,5 +1,5 @@ use super::widgets::{check, choices, heading, hint, path_label, row}; -use super::*; +use super::{LightcraftApp, RichText, StartupView, Tokens, json, register}; // ------------------------------------------------------------------------------------- General diff --git a/crates/ui-egui/src/dialogs/settings/import.rs b/crates/ui-egui/src/dialogs/settings/import.rs index c7272d366..97f34d6ab 100644 --- a/crates/ui-egui/src/dialogs/settings/import.rs +++ b/crates/ui-egui/src/dialogs/settings/import.rs @@ -1,6 +1,6 @@ use super::general::{cameras, preset_combo}; use super::widgets::{button_width, check, choices, heading, hint, path_label, row}; -use super::*; +use super::{LightcraftApp, RichText, Tokens, json, register}; pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let d = app.session.import_defaults.clone(); diff --git a/crates/ui-egui/src/dialogs/settings/libraries.rs b/crates/ui-egui/src/dialogs/settings/libraries.rs index 389f79f11..8e41e8dd4 100644 --- a/crates/ui-egui/src/dialogs/settings/libraries.rs +++ b/crates/ui-egui/src/dialogs/settings/libraries.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, Value, json}; // ------------------------------------------------------------------------------- Open Library… diff --git a/crates/ui-egui/src/dialogs/settings/performance.rs b/crates/ui-egui/src/dialogs/settings/performance.rs index daf2fa435..e65402c20 100644 --- a/crates/ui-egui/src/dialogs/settings/performance.rs +++ b/crates/ui-egui/src/dialogs/settings/performance.rs @@ -1,7 +1,7 @@ use super::widgets::{check, choices, heading, hint, row}; #[cfg(not(target_arch = "wasm32"))] use super::widgets::{label_width, path_label}; -use super::*; +use super::{CACHE_SIZES, LABEL_W, LightcraftApp, PREVIEW_LIMITS, RichText, Tokens, Value, json, register}; // --------------------------------------------------------------------------------- Performance diff --git a/crates/ui-egui/src/dialogs/settings/slide_overlays.rs b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs index 3d7412b58..fe72ed6a6 100644 --- a/crates/ui-egui/src/dialogs/settings/slide_overlays.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs @@ -30,14 +30,21 @@ fn set(app: &mut LightcraftApp, p: Value) { let _ = app.run("slideshow.overlays", p); } -/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request. -fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) { - let (k, suffix) = match unit { +/// The span of a slider and how its value is shown. +struct Range { + lo: f64, + hi: f64, + unit: Unit, +} + +/// A slider (`slider:{id}`) over `range`; `wrap(value)` makes the `slideshow.overlays` request. +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, range: Range, wrap: &dyn Fn(f64) -> Value) { + let (k, suffix) = match range.unit { Unit::Percent => (100.0, " %"), Unit::Degrees => (1.0, "°"), }; let mut v = value * k; - let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1)); + let r = ui.add(egui::Slider::new(&mut v, range.lo * k..=range.hi * k).suffix(suffix).max_decimals(1)); register(ui, format!("slider:{id}"), r.rect); let p = wrap(v / k); if r.changed() @@ -125,10 +132,14 @@ fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p }); } row(ui, t, "Offset X", |ui| { - slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v}))) + slider(app, ui, &format!("{id}OffsetX"), p.offset_x, Range { lo: -OFFSET_MAX / 2.0, hi: OFFSET_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"offsetX": v})) + }) }); row(ui, t, "Offset Y", |ui| { - slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v}))) + slider(app, ui, &format!("{id}OffsetY"), p.offset_y, Range { lo: -OFFSET_MAX / 2.0, hi: OFFSET_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"offsetY": v})) + }) }); } @@ -171,8 +182,12 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v})))); row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c})))); } - row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v})))); - row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowPlateSize", p.size, Range { lo: 0.01, hi: PLATE_SIZE_MAX, unit: Unit::Percent }, &|v| plate(json!({"size": v}))) + }); + row(ui, t, "Opacity", |ui| { + slider(app, ui, "slideshowPlateOpacity", p.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| plate(json!({"opacity": v}))) + }); placement(app, ui, t, "slideshowPlate", &p.place, true, &plate); toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v}))); } @@ -183,7 +198,9 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let stars = |v: Value| json!({ "ratingStars": v }); if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) { row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c})))); - row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowStarsSize", r.size, Range { lo: 0.01, hi: STAR_SIZE_MAX, unit: Unit::Percent }, &|v| stars(json!({"size": v}))) + }); placement(app, ui, t, "slideshowStars", &r.place, true, &stars); } @@ -205,24 +222,36 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; // removed: the indices moved; drawn again next frame } row(ui, t, "Size", |ui| { - slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v}))) + slider(app, ui, &format!("{id}Size"), o.size, Range { lo: 0.01, hi: TEXT_SIZE_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"size": v})) + }) }); row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c})))); - row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v})))); + row(ui, t, "Opacity", |ui| { + slider(app, ui, &format!("{id}Opacity"), o.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| wrap(json!({"opacity": v}))) + }); placement(app, ui, t, &id, &o.place, true, &wrap); let sh = o.shadow; if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) { row(ui, t, "Opacity", |ui| { - slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v}))) + slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowOpacity": v})) + }) }); row(ui, t, "Offset", |ui| { - slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v}))) + slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, Range { lo: 0.0, hi: SHADOW_OFFSET_MAX, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowOffset": v})) + }) }); row(ui, t, "Radius", |ui| { - slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v}))) + slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, Range { lo: 0.0, hi: SHADOW_RADIUS_MAX, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowRadius": v})) + }) }); row(ui, t, "Angle", |ui| { - slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v}))) + slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, Range { lo: -180.0, hi: 180.0, unit: Unit::Degrees }, &|v| { + wrap(json!({"shadowAngle": v})) + }) }); } } @@ -241,9 +270,13 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let wm = |v: Value| json!({ "watermark": v }); if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) { row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v})))); - row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowWatermarkSize", w.size as f64, Range { lo: 0.01, hi: 0.15, unit: Unit::Percent }, &|v| wm(json!({"size": v}))) + }); row(ui, t, "Opacity", |ui| { - slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v}))) + slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, Range { lo: 0.05, hi: 1.0, unit: Unit::Percent }, &|v| { + wm(json!({"opacity": v})) + }) }); row(ui, t, "Position", |ui| { let mut a = w.anchor; diff --git a/crates/ui-egui/src/dialogs/settings/slideshow.rs b/crates/ui-egui/src/dialogs/settings/slideshow.rs index b67427c40..a11bbfff6 100644 --- a/crates/ui-egui/src/dialogs/settings/slideshow.rs +++ b/crates/ui-egui/src/dialogs/settings/slideshow.rs @@ -1,6 +1,6 @@ use super::display::duration_slider; use super::widgets::{check, choices, heading, hint, row}; -use super::*; +use super::{LightcraftApp, Tokens, Value, json, register}; /// 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 diff --git a/crates/ui-egui/src/dialogs/settings/widgets.rs b/crates/ui-egui/src/dialogs/settings/widgets.rs index 3aea00b44..7607b1251 100644 --- a/crates/ui-egui/src/dialogs/settings/widgets.rs +++ b/crates/ui-egui/src/dialogs/settings/widgets.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LABEL_W, RichText, Tokens, register}; /// `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`, diff --git a/crates/ui-egui/src/dialogs/slideshow_export.rs b/crates/ui-egui/src/dialogs/slideshow_export.rs index 7a898b5db..0218d613e 100644 --- a/crates/ui-egui/src/dialogs/slideshow_export.rs +++ b/crates/ui-egui/src/dialogs/slideshow_export.rs @@ -5,8 +5,8 @@ use serde_json::{Value, json}; +use super::Tokens; use super::frame::{choices, field}; -use super::*; use crate::LightcraftApp; use crate::pick::{PickKind, PickRequest, Picked}; diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index 5e159a0f4..bdd2a81eb 100644 --- a/crates/ui-egui/src/dialogs/updates_ui.rs +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -26,7 +26,7 @@ impl Default for Runtime { fn default() -> Self { Self { #[cfg(not(target_arch = "wasm32"))] - updater: Updater::new(&craft_update::suite::LIGHTCRAFT, env!("CARGO_PKG_VERSION")), + updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), #[cfg(not(target_arch = "wasm32"))] startup_check: false, #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 1aec84820..206161102 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -232,6 +232,35 @@ pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(target_arch = "wasm32")] pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {} +/// What a finished export says: how many files were written, skipped or failed, and the Apple Photos note. +fn export_summary(files: &[Value], photos: Option<&Value>, total: usize, cancelled: bool) -> String { + let sheet = files.first().filter(|f| f["contactSheet"] == true); + let ok = files.iter().filter(|f| f.get("path").is_some()).count(); + let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect(); + let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count(); + let mut m = crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total); + if let Some(sheet) = sheet { + m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); + } + if let Some(pdf) = files.first().filter(|f| f["print"] == true) { + m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); + } + if skipped > 0 { + m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); + } + if let Some(first) = failed.first() { + m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); + } + if cancelled { + m += " · cancelled"; + } + if let Some(photos) = photos { + m += " · "; + m += &apple_photos_note(photos); + } + m +} + /// Per frame: keep the activity row up to date; when the batch finishes, report it. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(task) = &app.export else { return }; @@ -244,31 +273,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some()); let msg = match r { Ok((files, photos, _)) => { - let sheet = files.first().filter(|f| f["contactSheet"] == true); - let ok = files.iter().filter(|f| f.get("path").is_some()).count(); - let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect(); - let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count(); - let mut m = - crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total); - if let Some(sheet) = sheet { - m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); - } - if let Some(pdf) = files.first().filter(|f| f["print"] == true) { - m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); - } - if skipped > 0 { - m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); - } - if let Some(first) = failed.first() { - m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); - } - if cancelled { - m += " · cancelled"; - } - if let Some(photos) = &photos { - m += " · "; - m += &apple_photos_note(photos); - } + let m = export_summary(&files, photos.as_ref(), total, cancelled); app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos})); m } diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs index f38ed636a..13bd7a2ae 100644 --- a/crates/ui-egui/src/headless/tests.rs +++ b/crates/ui-egui/src/headless/tests.rs @@ -2775,6 +2775,109 @@ fn browse_a_local_folder() { let _ = std::fs::remove_dir_all(&dir); } +fn hide_rects(h: &mut Headless) -> Vec<(String, Vec)> { + let w = h.request("ui.widgets", json!({"filter": "source:local:"}), Duration::from_secs(10)); + w["result"] + .as_array() + .unwrap() + .iter() + .map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect())) + .collect() +} + +fn hide_ids(h: &mut Headless) -> Vec { + hide_rects(h).into_iter().map(|(id, _)| id).collect() +} + +fn hide_menu(h: &mut Headless) -> Vec { + let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), Duration::from_secs(10)); + w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() +} + +/// Hides `path` through the command and settles the frames. +fn hide_path(h: &mut Headless, path: &str) { + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// Keeps and browses `path` the way Browse Folder… does; returns whether anything was hidden before. +fn hide_browse_and_hide(h: &mut Headless, path: &str, size: [f32; 2]) -> bool { + let t = Duration::from_secs(10); + // Browse Folder… browses the picked folder and keeps it in Local within one frame; a + // request runs frames, so keep it first (no frame sees it browsed but not yet kept) + let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let row = format!("source:local:{path}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", hide_ids(h)); + // the "Show hidden locations" row appears only while something is hidden + let hidden_before = !h.app.ui.hidden_locations.is_empty(); + assert_eq!(hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before); + // hide it while it is still being browsed + hide_path(h, path); + // give any listing a reveal would wait for time to arrive + for _ in 0..20 { + h.step(); + std::thread::sleep(Duration::from_millis(5)); + } + h.settle(SETTLE); + let after = hide_rects(h); + let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect(); + assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}"); + let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone()); + let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}")); + assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); + assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); + hidden_before +} + +/// A second hidden place: the row lists them, and each comes back on its own (issue #2114: +/// the row used to bring every hidden place back at once). +fn hide_second_place_restores_alone(h: &mut Headless, path: &str, path2: &str) { + let t = Duration::from_secs(10); + for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + assert!(hide_menu(h).is_empty(), "the list opens from the row"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + let items = hide_menu(h); + assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); + h.settle(SETTLE); + assert!(!h.app.ui.hidden_locations.iter().any(|x| x == path), "{:?}", h.app.ui.hidden_locations); + assert!(h.app.ui.hidden_locations.iter().any(|x| x == path2), "the other place stays hidden"); + assert!(hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); +} + +fn hide_restore_all_then_single(h: &mut Headless, path: &str, path2: &str, hidden_before: bool) { + let t = Duration::from_secs(10); + // two hidden again: Restore All brings every one back + hide_path(h, path); + assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); + assert!(!hide_ids(h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); + // one hidden: the row brings it back itself, no list + hide_path(h, path2); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(hide_menu(h).is_empty(), "no list for a single hidden place"); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); + if !hidden_before { + // (with Home hidden for this test, the folder is back inside Home's tree instead) + assert!(hide_ids(h).contains(&format!("source:local:{path}"))); + } +} + /// Local: Remove from Local hides a sidebar location (nothing on disk changes) and the /// "Show hidden locations" row puts it back. The folder is browsed and kept the way Browse /// Folder… does it, in the temporary folder — on Windows beneath Home, whose tree must not @@ -2788,96 +2891,16 @@ fn local_location_can_be_hidden_and_restored() { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.to_string_lossy().to_string(); - let rects = |h: &mut Headless| -> Vec<(String, Vec)> { - let w = h.request("ui.widgets", json!({"filter": "source:local:"}), t); - w["result"] - .as_array() - .unwrap() - .iter() - .map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"].as_array().unwrap().iter().filter_map(|v| v.as_f64()).collect())) - .collect() - }; - let ids = |h: &mut Headless| -> Vec { rects(h).into_iter().map(|(id, _)| id).collect() }; h.request("ui.set", json!({"leftPanel": true}), t); h.hide_home_above(&dir); - // Browse Folder… browses the picked folder and keeps it in Local within one frame; a - // request runs frames, so keep it first (no frame sees it browsed but not yet kept) - let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - let row = format!("source:local:{path}"); - assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "the browsed folder is listed: {:?}", ids(&mut h)); - // the "Show hidden locations" row appears only while something is hidden - let hidden_before = !h.app.ui.hidden_locations.is_empty(); - assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), hidden_before); - // hide it while it is still being browsed - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - // give any listing a reveal would wait for time to arrive - for _ in 0..20 { - h.step(); - std::thread::sleep(Duration::from_millis(5)); - } - h.settle(SETTLE); - let after = rects(&mut h); - let after_ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect(); - assert!(!after_ids.contains(&format!("source:local:{path}").as_str()), "{after_ids:?}"); - let restore = after.iter().find(|(id, _)| id == "source:local:restoreHidden").map(|(_, r)| r.clone()); - let restore = restore.unwrap_or_else(|| panic!("no restore row: {after_ids:?}")); - assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); - assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); + let hidden_before = hide_browse_and_hide(&mut h, &path, size); assert!(dir.is_dir(), "the folder itself is untouched"); - // a second hidden place: the row lists them, and each comes back on its own (issue #2114: - // the row used to bring every hidden place back at once) let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id())); let _ = std::fs::remove_dir_all(&dir2); std::fs::create_dir_all(&dir2).unwrap(); let path2 = dir2.to_string_lossy().to_string(); - for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { - let r = h.request("engine.execute", json!({"command": command, "params": params}), t); - assert_eq!(r["ok"], true, "{r}"); - } - h.settle(SETTLE); - let menu = |h: &mut Headless| -> Vec { - let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t); - w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() - }; - assert!(menu(&mut h).is_empty(), "the list opens from the row"); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - let items = menu(&mut h); - assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); - assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); - h.settle(SETTLE); - assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); - assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); - assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); - // two hidden again: Restore All brings every one back - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); - h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); - assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); - // one hidden: the row brings it back itself, no list - let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); - assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); - h.settle(SETTLE); - assert!(menu(&mut h).is_empty(), "no list for a single hidden place"); - assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); - if !hidden_before { - // (with Home hidden for this test, the folder is back inside Home's tree instead) - assert!(ids(&mut h).contains(&format!("source:local:{path}"))); - } + hide_second_place_restores_alone(&mut h, &path, &path2); + hide_restore_all_then_single(&mut h, &path, &path2, hidden_before); let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir2); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8be0b3e8e..a7df011c2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -262,6 +262,12 @@ fn catalog_verbatim(language: Locale) -> BTreeMap { language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() } +/// `"s"` for a count other than one: the plural ending of English messages. Computed before a [`tr_format!`] rather +/// than inside it, since the macro repeats its arguments once per language. +pub(crate) fn plural_s(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + // `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from // `locales/*-formats.json`. include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 2a44072f0..77bbc14cb 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -214,6 +214,32 @@ fn switching_language_translates_and_restores() { assert_eq!(tr("Exposure"), "Exposure"); } +fn assert_format_specs(language: &Locale) { + let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); + assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); + let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); + assert!(merging.contains("42%"), "{language:?}: {merging}"); + let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); + assert!(delta.contains("+5"), "{language:?}: {delta}"); + let reading = tr_format!("Reading photos… {} of {}", 3, 10); + let (three, ten) = (reading.find('3'), reading.find("10")); + assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); +} + +fn assert_format_values_kept(language: &Locale) { + let name = "Color 1234.56 {title} 日本語"; + let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); + assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); + for n in [0, 1, 2, 5, 22] { + let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); + assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); + } + // The trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); + assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); +} + /// Format strings carry their values in every language, and a language without a translation /// falls back to the English format rather than dropping the values. #[test] @@ -228,26 +254,8 @@ fn formats_render_in_every_language() { // text (an album name with braces and CJK) and counts are never dropped. for language in Locale::ALL { set_language(*language); - let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); - assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); - let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); - assert!(merging.contains("42%"), "{language:?}: {merging}"); - let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); - assert!(delta.contains("+5"), "{language:?}: {delta}"); - let reading = tr_format!("Reading photos… {} of {}", 3, 10); - let (three, ten) = (reading.find('3'), reading.find("10")); - assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); - let name = "Color 1234.56 {title} 日本語"; - let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); - assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); - for n in [0, 1, 2, 5, 22] { - let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); - assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); - } - // The trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); - assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); + assert_format_specs(language); + assert_format_values_kept(language); } set_language(Locale::En); assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); @@ -1128,24 +1136,7 @@ fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_d 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"); +fn assert_polish_counts() { 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}")); @@ -1159,20 +1150,20 @@ fn polish_switches_formats_dates_and_reads_every_count() { 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::(&saved).unwrap().language, Locale::Pl); - let ctx = egui::Context::default(); +} + +fn assert_polish_selected_by_tags(app: &mut crate::LightcraftApp, ctx: &egui::Context) { + use crate::control::{ControlRequest, Outcome}; 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") }; + let Outcome::Done(reply) = crate::control::handle(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}"); +} + +fn assert_polish_glyphs(ctx: &egui::Context) { 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); @@ -1181,5 +1172,34 @@ fn polish_switches_formats_dates_and_reads_every_count() { } } }); +} + +/// 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() { + 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"); + assert_polish_counts(); + 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::(&saved).unwrap().language, Locale::Pl); + let ctx = egui::Context::default(); + assert_polish_selected_by_tags(&mut app, &ctx); + let text = painted_text(&ctx, &mut app, Locale::Pl); + assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); + assert_polish_glyphs(&ctx); set_language(Locale::En); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9db7035c1..8bf57b341 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,15 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// LightCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.lightcraft.png")), + accent: Some([0xf2, 0xa5, 0x16]), + ..craft_update::suite::LIGHTCRAFT +}; + pub mod brush_live; pub mod canvas_keys; mod command_error; diff --git a/crates/ui-egui/src/menus/files.rs b/crates/ui-egui/src/menus/files.rs index 52389d930..d75282e57 100644 --- a/crates/ui-egui/src/menus/files.rs +++ b/crates/ui-egui/src/menus/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, LightcraftApp, Value, json}; /// The Delete key in an album: a normal album loses the selected photos (`album.removePhotos`), /// which stay in the library and their folder; a smart album keeps them (its rules decide) and diff --git a/crates/ui-egui/src/menus/menubar.rs b/crates/ui-egui/src/menus/menubar.rs index 5c1bcb9fc..d96674306 100644 --- a/crates/ui-egui/src/menus/menubar.rs +++ b/crates/ui-egui/src/menus/menubar.rs @@ -167,11 +167,13 @@ const LAYOUT: &[(&str, &[&str])] = &[ "dialog.copySettings", "develop.paste", "dialog.pasteSettings", + "develop.pastePrevious", "develop.sync", "develop.autoSync", "---", "library.selectAll", "library.selectNone", + "library.selectOnlyActive", "@Select by", "---", "view.focusSearch", diff --git a/crates/ui-egui/src/menus/menubar/tests.rs b/crates/ui-egui/src/menus/menubar/tests.rs index 1e2fe388d..72fc077d1 100644 --- a/crates/ui-egui/src/menus/menubar/tests.rs +++ b/crates/ui-egui/src/menus/menubar/tests.rs @@ -823,3 +823,20 @@ fn no_two_menu_commands_share_a_label() { } assert!(clashes.is_empty(), "{clashes:#?}"); } + +/// Edit lists Select Only Active Photo with the other Select rows and Paste Settings from Previous +/// with the other Paste rows, not after Settings…, near the end of the menu. +#[test] +fn edit_menu_places_the_select_and_paste_rows_with_their_kin() { + let a = app(); + let bar = menu_bar(&a); + let nodes = &bar.iter().find(|(title, _)| title == "Edit").unwrap().1; + let pos = |id: &str| { + nodes.iter().position(|n| matches!(n, MenuNode::Item { id: i, .. } if i == id)).unwrap_or_else(|| panic!("{id} is a top-level Edit row")) + }; + assert_eq!(pos("library.selectOnlyActive"), pos("library.selectNone") + 1, "Select Only Active Photo follows Deselect All"); + assert_eq!(pos("develop.pastePrevious"), pos("dialog.pasteSettings") + 1, "Paste Settings from Previous follows Paste Selected Settings…"); + assert!(pos("app.settings") > pos("library.selectOnlyActive"), "Settings… stays after them"); + let after = &nodes[pos("app.settings") + 1..]; + assert!(!after.iter().any(|n| matches!(n, MenuNode::Item { .. })), "no row follows Settings… but a submenu: {after:?}"); +} diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index c550ff0d4..c88a50207 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::files::{edit_in, open_log_folder, open_original_in, show_in_finder, show_in_folders}; use super::ui_commands::{UI_COMMANDS, language_from_command}; -use super::*; +use super::{BeforeAfter, Dialog, LightcraftApp, PickRequest, Picked, RightPanel, Value, ViewMode, Zoom, json, thumb_size_view}; fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { @@ -168,6 +168,25 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option>; 12] = [ + ui_commands_1, + ui_commands_2, + ui_commands_3, + ui_commands_4, + ui_commands_5, + ui_commands_6, + ui_commands_7, + ui_commands_8, + ui_commands_9, + ui_commands_10, + ui_commands_11, + ui_commands_12, + ]; + PARTS.iter().find_map(|part| part(app, &ctx, id, p)) +} + +/// Part 1 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_1(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { let r: Result = match id { "view.photoGrid" => { app.ui.view = ViewMode::PhotoGrid; @@ -203,7 +222,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option return None, + }; + Some(r) +} + +/// Part 2 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_2(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.detail" => { app.ui.view = ViewMode::Detail; Ok(Value::Null) @@ -369,47 +396,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::make_select(app), "view.autoAdvance" => { app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); + app.toast(ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); Ok(json!({"autoAdvance": app.ui.auto_advance})) } - "view.back" => { - if app.ui.dialog.is_some() { - app.ui.dialog = None; - } else if app.ui.lights_out != crate::state::LightsOut::On { - app.ui.lights_out = crate::state::LightsOut::On; - } else if app.ui.keyword_painter.is_some() { - app.ui.keyword_painter = None; - } else if app.ui.fullscreen { - // Through `slideshow::end`: it also releases the held slide textures. Clearing the - // two fields directly left every rendered slide in the renderer. - crate::panels::slideshow::end(app); - } else if !app.ui.tool.is_empty() { - app.ui.tool.clear(); - } else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail { - // Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); - } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { - app.ui.person_page = None; - } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } else if app.ui.view == ViewMode::Detail { - // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid - match app.ui.person_from.take() { - Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { - app.ui.view = ViewMode::People; - app.ui.person_page = name; - } - _ => app.ui.view = ViewMode::PhotoGrid, - } - } - Ok(Value::Null) - } + "view.back" => view_back_command(app, ctx), "view.slideshow" => { // the photos in view, full screen, with the playback settings (see panels::slideshow) let now = app.tick_now(); let r = crate::panels::slideshow::start(app, p, now); if r.is_ok() { - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); + app.toast(ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); } r } @@ -417,6 +413,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 3 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_3(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.slideshowOptions" => { app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); Ok(Value::Null) @@ -464,51 +468,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option "Info Overlay: File", crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", }; - app.toast(&ctx, crate::i18n::tr(label)); + app.toast(ctx, crate::i18n::tr(label)); Ok(json!({"infoOverlay": app.ui.info_overlay})) } - "view.viewOptions" => { - // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes - // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; - // without any of them the window opens - let mut info = app.ui.settings.loupe_info; - let mut set = false; - // `loupeMessages` sets Show message when loading or rendering photos - let messages = match p.get("loupeMessages") { - None => app.ui.settings.loupe_messages, - Some(v) => match v.as_bool() { - Some(on) => { - set = true; - on - } - None => return Some(Err("`loupeMessages`: true|false".into())), - }, - }; - for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { - let Some(v) = p.get(key) else { continue }; - match serde_json::from_value(v.clone()) { - Ok(v) => *lines = v, - Err(_) => { - return Some(Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens"))); - } - } - set = true; - } - if let Some(v) = p.get("briefly") { - match serde_json::from_value(v.clone()) { - Ok(v) => info.briefly = v, - Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())), - } - set = true; - } - if set { - app.ui.settings.loupe_info = info; - app.ui.settings.loupe_messages = messages; - } else { - app.ui.dialog = Some(Dialog::ViewOptions); - } - Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) - } + "view.viewOptions" => view_view_options_command(app, p), "view.chooseLayoutImage" => { let path = match p.get("path").and_then(Value::as_str) { Some(path) => path.trim().to_string(), @@ -531,7 +494,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -541,7 +504,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -561,6 +524,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 4 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_4(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "app.settings" => { let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { @@ -665,6 +636,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 5 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_5(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.clickZoom" => { // {ratio?: 1|2|3|4|8} → {ratio} if let Some(r) = p.get("ratio").and_then(Value::as_f64) { @@ -733,67 +712,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, - // destWarning?, displayWarning?}; no params toggles - let has = |k: &str| p.get(k).is_some(); - if let Some(s) = p.get("space").and_then(Value::as_str) { - match lightcraft_engine::pipeline::OutputSpace::parse(s) { - Some(sp) => { - app.ui.proof.space = sp; - // a standard space replaces a device profile (unless one is given too) - app.ui.proof_profile = None; - } - None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), - } - } - if let Some(i) = p.get("intent") { - match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { - Some(i) => app.ui.proof_intent = i, - None => return Some(Err("view.softProof: intent must be perceptual or relative".into())), - } - } - if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { - app.ui.proof_paper_ink = b; - } - let path = match p.get("profile") { - Some(Value::String(s)) => Some(s.clone()), - Some(Value::Null) => { - app.ui.proof_profile = None; - None - } - Some(_) => return Some(Err("view.softProof: profile must be an ICC file path or null".into())), - None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), - }; - match path { - Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { - Ok(pp) => { - app.ui.proof.profile = pp.id; - app.ui.proof_profile = Some((path, pp.name)); - } - Err(e) => return Some(Err(format!("view.softProof: {e}"))), - }, - None => app.ui.proof.profile = 0, - } - if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { - app.ui.proof.dest_warning = b; - } - if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { - app.ui.proof.display_warning = b; - } - app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { - Some(b) => b, - None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, - None => !app.ui.soft_proof, - }; - if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } - let pr = app.ui.proof; - let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); - Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), - "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) - } + "view.softProof" => view_soft_proof_command(app, id, p), "view.histogram" => { app.ui.histogram = !app.ui.histogram; Ok(Value::Null) @@ -821,9 +740,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 6 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_6(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "view.maskOverlayColor" => { // no params: the next of the panel's swatch colours if p.get("color").is_none() && p.get("opacity").is_none() { @@ -876,7 +803,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -932,6 +859,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Err("local.hide needs a path".into()), }, + _ => return None, + }; + Some(r) +} + +/// Part 7 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_7(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "local.restoreHidden" => { // one path, or (no path) every hidden location match p.get("path").and_then(Value::as_str) { @@ -952,7 +887,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - panel(app, &ctx, RightPanel::Edit, "Edit"); + panel(app, ctx, RightPanel::Edit, "Edit"); Ok(Value::Null) } "panel.profiles" => { @@ -968,47 +903,47 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + panel(app, ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); Ok(Value::Null) } "panel.remove" => { - panel(app, &ctx, RightPanel::Remove, "Remove"); + panel(app, ctx, RightPanel::Remove, "Remove"); if app.ui.right == RightPanel::Remove && app.ui.tool.is_empty() { app.ui.tool = "remove".into(); } Ok(Value::Null) } "panel.masking" => { - panel(app, &ctx, RightPanel::Masking, "Masking"); + panel(app, ctx, RightPanel::Masking, "Masking"); Ok(Value::Null) } "panel.redeye" => { - panel(app, &ctx, RightPanel::RedEye, "Red Eye"); + panel(app, ctx, RightPanel::RedEye, "Red Eye"); Ok(Value::Null) } "panel.info" => { - panel(app, &ctx, RightPanel::Info, "Info"); + panel(app, ctx, RightPanel::Info, "Info"); Ok(Value::Null) } "panel.keywords" => { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); + panel(app, ctx, RightPanel::Keywords, "Keywords"); Ok(Value::Null) } "panel.addKeywords" => { // opens Keywording when it is folded shut; the panel stays open when pressed again if app.ui.right != RightPanel::Keywords { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); + panel(app, ctx, RightPanel::Keywords, "Keywords"); } app.ui.folded_sections.retain(|s| s != "keywording"); app.ui.focus_keywords = Some(ctx.cumulative_pass_nr()); Ok(Value::Null) } "panel.versions" => { - panel(app, &ctx, RightPanel::Versions, "Versions"); + panel(app, ctx, RightPanel::Versions, "Versions"); Ok(Value::Null) } "panel.activity" => { - panel(app, &ctx, RightPanel::Activity, "History"); + panel(app, ctx, RightPanel::Activity, "History"); Ok(Value::Null) } "panel.presets" => { @@ -1033,6 +968,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 8 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_8(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { s if s.starts_with("section.") => bool_param(s, p, "solo").and_then(|solo| { // only the Edit sections listed above: an unknown one would close the real ones if !UI_COMMANDS.iter().any(|c| c.0 == s) { @@ -1133,41 +1076,19 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 - let album = p - .get("id") - .and_then(Value::as_u64) - .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); - app.ui.dialog = Some(match album { - Some(a) => Dialog::SmartRules { - id: Some(a.id.0), - name: a.name.clone(), - // older rules read as they always matched (RuleSet::upgrade) - rules: a - .smart - .as_ref() - .and_then(|f| f.rule_set.clone()) - .map(|mut r| { - r.upgrade(); - r - }) - .unwrap_or_default(), - parent: None, - }, - None => Dialog::SmartRules { - id: None, - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: parent_param(p), - }, - }); - Ok(Value::Null) - } + "dialog.smartAlbum" => dialog_smart_album_command(app, id, p), "dialog.newSmartAlbum" => { app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) }); Ok(Value::Null) } + _ => return None, + }; + Some(r) +} + +/// Part 9 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_9(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "dialog.captureTime" => { // a time that doesn't fully read isn't offered as the starting point (`10:0` would read as midnight) let time = app @@ -1268,6 +1189,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return None, + }; + Some(r) +} + +/// Part 10 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_10(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "dialog.export" => { let prev = app.session.last_export.clone().unwrap_or_default(); let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); @@ -1303,9 +1232,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), - "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), - "photo.editInExternal" => edit_in(app, &ctx, p, false), - "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), + "photo.editInPhotoCraft" => edit_in(app, ctx, p, true), + "photo.editInExternal" => edit_in(app, ctx, p, false), + "photo.openOriginalInExternal" => open_original_in(app, ctx, p), "dialog.cull" => { 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 }); @@ -1315,51 +1244,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); - let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); - let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); - let mut rows = vec![ - ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), - ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), - ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), - ( - "GPU".to_string(), - gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { - Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), - None => "none (CPU rendering)".into(), - }), - ), - ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), - ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), - ( - "Preview size".to_string(), - match app.ui.settings.preview_limit { - 0 => "automatic".to_string(), - n => format!("{n} px"), - }, - ), - ("Photos".to_string(), info["photos"].to_string()), - ("Albums".to_string(), info["albums"].to_string()), - ]; - if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { - rows.push(("Library".into(), dir.to_string())); - } - rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); - rows.push(( - "Frame update".into(), - format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), - )); - rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); - if let Some(f) = lightcraft_engine::gpu::last_fallback() { - rows.push(("Last GPU fallback".into(), f)); - } - let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); - if p.get("open").and_then(Value::as_bool).unwrap_or(true) { - app.ui.dialog = Some(Dialog::SystemInfo { rows }); - } - Ok(r) - } + "app.systemInfo" => app_system_info_command(app, p), "app.shortcuts" => { app.ui.dialog = Some(Dialog::Shortcuts); Ok(Value::Null) @@ -1420,6 +1305,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Ok(Value::Null), } } + _ => return None, + }; + Some(r) +} + +/// Part 11 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_11(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { "app.quit" => { app.ui.quit = true; Ok(Value::Null) @@ -1429,180 +1322,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let within = match p.get("within") { - None | Some(Value::Null) => None, - Some(Value::String(path)) if !path.is_empty() => Some(path.clone()), - Some(Value::String(_)) => return Some(Err("file.findMissing: `within` must not be empty".into())), - Some(value) => return Some(Err(format!("file.findMissing: `within` must be a folder path, not {value}"))), - }; - let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); - let timeout = match p.get("timeout") { - None | Some(Value::Null) => std::time::Duration::from_secs(600), - Some(Value::Number(n)) => { - if let Some(s) = n.as_f64() { - if s.is_finite() && s >= 0.0 { - match std::time::Duration::try_from_secs_f64(s) { - Ok(d) => d, - Err(_) => return Some(Err("file.findMissing: `timeout` is too large".into())), - } - } else { - return Some(Err("file.findMissing: `timeout` must be a non-negative number of seconds".into())); - } - } else { - return Some(Err("file.findMissing: `timeout` must be a number of seconds".into())); - } - } - Some(other) => return Some(Err(format!("file.findMissing: `timeout` must be a number of seconds, not {other}"))), - }; - let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; - let folder = match p.get("folder").and_then(Value::as_str) { - Some(f) => Some(f.to_string()), - None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { - s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - }, - }; - let Some(folder) = folder else { return Some(Ok(Value::Null)) }; - // the search (checking every photo's file, walking the folder) runs on a worker thread; - // the relinking happens back here, as one undo step - const LABEL: &str = "Find Missing Photos"; - if app.tasks.is_running(LABEL) { - return Some(Err("Find Missing Photos is already searching".into())); - } - let mut candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); - if let Some(within) = within { - candidates.retain(|candidate| lightcraft_catalog::query::folder_within(&candidate.path, &within)); - } - #[cfg(test)] - let test_delay = app.ui.find_missing_test_delay; - let work = move || { - #[cfg(test)] - if let Some(d) = test_delay { - std::thread::sleep(d); - } - lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder) - }; - let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result| { - // relinked under the session as it is now: photos relinked meanwhile and files - // now in use are skipped - let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); - match r { - Ok(v) => { - let n = v["found"].as_array().map_or(0, Vec::len); - let left = v["missing"].as_u64().unwrap_or(0); - let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); - let unsure = if unsure > 0 { - crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) - } else { - String::new() - }; - app.toast( - ctx, - crate::i18n::tr_format!( - "Found {n} missing photo{}; {left} still missing{unsure}", - if n == 1 { "" } else { "s" }, - left = left, - n = n, - unsure = unsure - ), - ); - app.ui.last_find_missing = Some(v.clone()); - app.ui.last_find_missing_result = Some(Ok(v)); - } - Err(e) => { - app.toast(ctx, e.clone()); - app.ui.last_find_missing_result = Some(Err(e)); - } - } - }; - app.ui.last_find_missing = None; - app.ui.last_find_missing_result = None; - if let Err(e) = crate::tasks::spawn(app, LABEL, Some("findMissing"), work, done) { - return Some(Err(e)); - } - if wait { - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - let completed = crate::tasks::wait_for(app, &ctx, LABEL, timeout); - if !completed { - return Some(Err("Find Missing Photos: search timed out (search continues in the background)".into())); - } - return match app.ui.last_find_missing_result.take() { - Some(Ok(v)) => Some(Ok(v)), - Some(Err(e)) => Some(Err(e)), - None => Some(Err("Find Missing Photos: worker terminated unexpectedly".into())), - }; - } - Ok(json!({"background": true})) - } - "photo.tagFromTracklog" => { - // a GPX file → GPS for the selected photos by capture time (one undo step) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = p.as_object().cloned().unwrap_or_default(); - params.insert("path".into(), json!(path)); - let ask_zone = !params.contains_key("offset"); - let params = Value::Object(params); - if ask_zone { - // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here - crate::panels::dialogs::prompt( - app, - "Auto-Tag from Tracklog", - "Camera time zone, e.g. -07:00 (empty: UTC)", - "", - "photo.tagFromTracklog", - params, - "offset", - ); - return Some(Ok(Value::Null)); - } - let r = app.run("photo.autoTagTracklog", params); - if let Ok(v) = &r { - let n = v["tagged"].as_u64().unwrap_or(0); - let sk = &v["skipped"]; - let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); - let outside = sk["outside"].as_u64().unwrap_or(0); - if outside > 0 { - msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); - } - let kept = sk["hasGps"].as_u64().unwrap_or(0); - if kept > 0 { - msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); - } - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - app.toast(&ctx, msg); - } - r - } - "photo.locate" => { - let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = - PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); - match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - match path { - Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), - None => Ok(Value::Null), - } - } + "file.findMissing" => file_find_missing_command(app, id, p), + "photo.tagFromTracklog" => photo_tag_from_tracklog_command(app, ctx, id, p), + "photo.locate" => photo_locate_command(app, p), "file.addFromDevice" => { // a camera / card: review its DCIM folder, copying into the library by default let path = match p.get("path").and_then(Value::as_str) { @@ -1640,208 +1362,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Ok(Value::Null), } } - "file.importPresets" => { - let paths = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Presets").into()), - crate::i18n::tr("Presets & Profiles"), - crate::pick::PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - // .cube LUTs (and those inside zips / folders) become profiles - let lut_paths: Vec<&String> = paths - .iter() - .filter(|p| { - let l = p.to_ascii_lowercase(); - l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() - }) - .collect(); - // one toast for both halves (a second toast would replace the first) - let mut parts: Vec = Vec::new(); - // `name: reason` for each file that couldn't be imported - let mut problems: Vec = Vec::new(); - let mut profiles_result = Value::Null; - if !lut_paths.is_empty() { - match app.session.execute("profile.import", &json!({"paths": lut_paths})) { - Ok(v) => { - let profiles = v["imported"].as_array().map_or(0, Vec::len); - if profiles > 0 { - parts.push(crate::i18n::tr_format!( - "Imported {profiles} profile{} (Profile browser ▸ their groups)", - if profiles == 1 { "" } else { "s" }, - profiles = profiles - )); - } - problems.extend(v["failed"].as_array().into_iter().flatten().map(|f| { - let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); - let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); - format!("{name}: {reason}") - })); - profiles_result = v; - } - Err(e) => { - problems.push(e.to_string()); - profiles_result = json!({"error": e.to_string()}); - } - } - } - let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); - let r = if preset_paths.is_empty() { - Ok(json!({"imported": [], "failed": [], "skipped": 0})) - } else { - app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) - }; - if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast - let effects = v["effects"].as_array().map_or(0, Vec::len); - if effects > 0 { - parts.push(crate::panels::mask_effects::import_toast(v)); - } - let profile_news = !parts.is_empty() || !problems.is_empty(); - let msg = match (n, failed) { - // a zip of LUTs (or only local presets): the other half already says what happened - (0, 0) if profile_news => None, - (0, 0) => Some("No new presets".to_string()), - (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), - (n, f) => Some(crate::i18n::tr_format!( - "Imported {n} preset{}, {f} file{} not readable", - if n == 1 { "" } else { "s" }, - if f == 1 { "" } else { "s" }, - f = f, - n = n - )), - }; - if let Some(mut msg) = msg { - // settings with no counterpart here (the other editor's profiles, masks…) - let mut skipped: Vec<&str> = v["imported"] - .as_array() - .into_iter() - .flatten() - .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) - .filter_map(Value::as_str) - .collect(); - skipped.sort_unstable(); - skipped.dedup(); - if !skipped.is_empty() { - let names: Vec<&str> = skipped.iter().take(3).copied().collect(); - msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); - } - parts.push(msg); - } - if n > 0 { - app.ui.presets = true; - } - } - let mut msg = parts.join(" · "); - if !problems.is_empty() { - let detail = - format!("{}{}", problems.iter().take(2).cloned().collect::>().join("; "), if problems.len() > 2 { "…" } else { "" }); - if msg.is_empty() { - msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); - } else { - msg += &crate::i18n::tr_format!(" · {} failed: {}", problems.len(), detail); - } - app.toast_error(&ctx, msg); - } else if !msg.is_empty() { - app.toast(&ctx, msg); - } - let r = r.map(|mut v| { - if !lut_paths.is_empty() - && let Some(o) = v.as_object_mut() - { - o.insert("profiles".into(), profiles_result); - } - v - }); - return Some(r); - } - "file.importKeywords" => { - // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); - match &r { - Ok(v) => { - let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); - let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); - if updated > 0 { - msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); - } - app.toast(&ctx, msg); - } - Err(e) => app.toast(&ctx, e.clone()), - } - return Some(r); - } - "file.exportKeywords" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let name = "Keywords.txt"; - let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); - if let Err(e) = &r { - app.toast(&ctx, e.clone()); - } - if let Ok(v) = &r { - let n = v["keywords"].as_u64().unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); - // Capture One's importer refuses ; , < > in a list: say which to rename first - let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !refuses.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — Capture One won't import a list with ; , < or > in a keyword: {names}", - names = refuses.iter().take(5).copied().collect::>().join(" · ") - )); - } - // what the format can't hold (a name in brackets, a line break…) isn't in the file - let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !left.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — left out, as a keyword list can't hold them: {names}", - // escaped: a line break in a name would break the toast - names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::>().join(" · ") - )); - } - app.toast(&ctx, msg); - } - return Some(r); - } + "file.importPresets" => file_import_presets_command(app, ctx, id, p), + "file.importKeywords" => file_import_keywords_command(app, ctx, id, p), + "file.exportKeywords" => file_export_keywords_command(app, ctx, id, p), "dialog.saveMaskEffect" => { if app.session.active_mask.is_none() { return Some(Err("select a mask first".into())); @@ -1853,38 +1376,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); - let req = PickRequest::save( - crate::i18n::tr("Export Effect Presets"), - crate::i18n::tr("LightCraft Effect Presets"), - &["lceffect"], - name.clone(), - ); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - for k in ["names", "group"] { - if let Some(v) = p.get(k) { - params[k] = v.clone(); - } - } - let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); - match &r { - Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), - Err(e) => app.toast_error(&ctx, e.clone()), - } - return Some(r); - } + "file.exportMaskEffects" => file_export_mask_effects_command(app, ctx, id, p), "file.exportPresets" => { let group = p.get("group").and_then(Value::as_str).map(str::to_string); let path = match p.get("path").and_then(Value::as_str) { @@ -1909,71 +1401,20 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let paths: Vec = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Point Curve Presets").into()), - crate::i18n::tr("Point Curve Presets"), - crate::pick::CURVE_PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); - if let Ok(v) = &r { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); - if failed > 0 { - msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); - } - app.toast(&ctx, msg); - } - return Some(r); - } - "file.exportCurvePresets" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::save( - crate::i18n::tr("Export Point Curve Presets"), - crate::i18n::tr("Point Curve Presets"), - &["lccurve"], - "Point Curves.lccurve", - ); - match crate::pick::ask(app, id, p, "path", req, |s| { - s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - if let Some(n) = p.get("names") { - params["names"] = n.clone(); - } - let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); - } - return Some(r); + r } + "file.importCurvePresets" => file_import_curve_presets_command(app, ctx, id, p), + _ => return None, + }; + Some(r) +} + +/// Part 12 of 12 of the UI commands; `None` means the id is not one of this part's. +fn ui_commands_12(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option> { + let r: Result = match id { + "file.exportCurvePresets" => file_export_curve_presets_command(app, ctx, id, p), "app.export" => crate::control::export_active(app, p), "app.showInFinder" => show_in_finder(app), "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)), @@ -1990,3 +1431,742 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Result { + if app.ui.dialog.is_some() { + app.ui.dialog = None; + } else if app.ui.lights_out != crate::state::LightsOut::On { + app.ui.lights_out = crate::state::LightsOut::On; + } else if app.ui.keyword_painter.is_some() { + app.ui.keyword_painter = None; + } else if app.ui.fullscreen { + // Through `slideshow::end`: it also releases the held slide textures. Clearing the + // two fields directly left every rendered slide in the renderer. + crate::panels::slideshow::end(app); + } else if !app.ui.tool.is_empty() { + app.ui.tool.clear(); + } else if app.ui.right == RightPanel::Crop && app.ui.view == ViewMode::Detail { + // Esc leaves the crop tool (the crop stays), as C does; the next Esc goes back + panel(app, ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { + app.ui.person_page = None; + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } else if app.ui.view == ViewMode::Detail { + // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid + match app.ui.person_from.take() { + Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { + app.ui.view = ViewMode::People; + app.ui.person_page = name; + } + _ => app.ui.view = ViewMode::PhotoGrid, + } + } + Ok(Value::Null) +} + +/// The `view.viewOptions` command. +fn view_view_options_command(app: &mut LightcraftApp, p: &Value) -> Result { + // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes + // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; + // without any of them the window opens + let mut info = app.ui.settings.loupe_info; + let mut set = false; + // `loupeMessages` sets Show message when loading or rendering photos + let messages = match p.get("loupeMessages") { + None => app.ui.settings.loupe_messages, + Some(v) => match v.as_bool() { + Some(on) => { + set = true; + on + } + None => return Err("`loupeMessages`: true|false".into()), + }, + }; + for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { + let Some(v) = p.get(key) else { continue }; + match serde_json::from_value(v.clone()) { + Ok(v) => *lines = v, + Err(_) => { + return Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens")); + } + } + set = true; + } + if let Some(v) = p.get("briefly") { + match serde_json::from_value(v.clone()) { + Ok(v) => info.briefly = v, + Err(_) => return Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into()), + } + set = true; + } + if set { + app.ui.settings.loupe_info = info; + app.ui.settings.loupe_messages = messages; + } else { + app.ui.dialog = Some(Dialog::ViewOptions); + } + Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) +} + +/// The `view.softProof` command. +fn view_soft_proof_command(app: &mut LightcraftApp, _id: &str, p: &Value) -> Result { + // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, + // destWarning?, displayWarning?}; no params toggles + let has = |k: &str| p.get(k).is_some(); + if let Some(s) = p.get("space").and_then(Value::as_str) { + match lightcraft_engine::pipeline::OutputSpace::parse(s) { + Some(sp) => { + app.ui.proof.space = sp; + // a standard space replaces a device profile (unless one is given too) + app.ui.proof_profile = None; + } + None => return Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)")), + } + } + if let Some(i) = p.get("intent") { + match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { + Some(i) => app.ui.proof_intent = i, + None => return Err("view.softProof: intent must be perceptual or relative".into()), + } + } + if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { + app.ui.proof_paper_ink = b; + } + let path = match p.get("profile") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Null) => { + app.ui.proof_profile = None; + None + } + Some(_) => return Err("view.softProof: profile must be an ICC file path or null".into()), + None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), + }; + match path { + Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { + Ok(pp) => { + app.ui.proof.profile = pp.id; + app.ui.proof_profile = Some((path, pp.name)); + } + Err(e) => return Err(format!("view.softProof: {e}")), + }, + None => app.ui.proof.profile = 0, + } + if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { + app.ui.proof.dest_warning = b; + } + if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { + app.ui.proof.display_warning = b; + } + app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { + Some(b) => b, + None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, + None => !app.ui.soft_proof, + }; + if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } + let pr = app.ui.proof; + let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); + Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), + "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) +} + +/// The `dialog.smartAlbum` command. +fn dialog_smart_album_command(app: &mut LightcraftApp, _id: &str, p: &Value) -> Result { + // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 + let album = p + .get("id") + .and_then(Value::as_u64) + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); + app.ui.dialog = Some(match album { + Some(a) => Dialog::SmartRules { + id: Some(a.id.0), + name: a.name.clone(), + // older rules read as they always matched (RuleSet::upgrade) + rules: a + .smart + .as_ref() + .and_then(|f| f.rule_set.clone()) + .map(|mut r| { + r.upgrade(); + r + }) + .unwrap_or_default(), + parent: None, + }, + None => Dialog::SmartRules { + id: None, + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: parent_param(p), + }, + }); + Ok(Value::Null) +} + +/// The `app.systemInfo` command. +fn app_system_info_command(app: &mut LightcraftApp, p: &Value) -> Result { + let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); + let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); + let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); + let mut rows = vec![ + ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), + ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), + ( + "GPU".to_string(), + gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { + Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), + None => "none (CPU rendering)".into(), + }), + ), + ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), + ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), + ( + "Preview size".to_string(), + match app.ui.settings.preview_limit { + 0 => "automatic".to_string(), + n => format!("{n} px"), + }, + ), + ("Photos".to_string(), info["photos"].to_string()), + ("Albums".to_string(), info["albums"].to_string()), + ]; + if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { + rows.push(("Library".into(), dir.to_string())); + } + rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); + rows.push(( + "Frame update".into(), + format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), + )); + rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); + if let Some(f) = lightcraft_engine::gpu::last_fallback() { + rows.push(("Last GPU fallback".into(), f)); + } + let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::>()); + if p.get("open").and_then(Value::as_bool).unwrap_or(true) { + app.ui.dialog = Some(Dialog::SystemInfo { rows }); + } + Ok(r) +} + +/// `file.findMissing {within?}`: the folder the search is limited to, if any. +fn find_missing_within(p: &Value) -> Result, String> { + match p.get("within") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(path)) if !path.is_empty() => Ok(Some(path.clone())), + Some(Value::String(_)) => Err("file.findMissing: `within` must not be empty".into()), + Some(value) => Err(format!("file.findMissing: `within` must be a folder path, not {value}")), + } +} + +/// `file.findMissing {timeout?}`: how long `wait` waits for the search (ten minutes when omitted). +fn find_missing_timeout(p: &Value) -> Result { + let n = match p.get("timeout") { + None | Some(Value::Null) => return Ok(std::time::Duration::from_secs(600)), + Some(Value::Number(n)) => n, + Some(other) => return Err(format!("file.findMissing: `timeout` must be a number of seconds, not {other}")), + }; + let s = n.as_f64().ok_or("file.findMissing: `timeout` must be a number of seconds")?; + if !s.is_finite() || s < 0.0 { + return Err("file.findMissing: `timeout` must be a non-negative number of seconds".into()); + } + std::time::Duration::try_from_secs_f64(s).map_err(|_| "file.findMissing: `timeout` is too large".to_string()) +} + +/// The toast for a finished search, and the result it leaves for a caller that waits. +fn find_missing_done(app: &mut LightcraftApp, ctx: &egui::Context, plan: Result) { + // relinked under the session as it is now: photos relinked meanwhile and files + // now in use are skipped + let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); + match r { + Ok(v) => { + let n = v["found"].as_array().map_or(0, Vec::len); + let left = v["missing"].as_u64().unwrap_or(0); + let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); + let unsure = if unsure > 0 { + crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) + } else { + String::new() + }; + app.toast( + ctx, + crate::i18n::tr_format!( + "Found {n} missing photo{}; {left} still missing{unsure}", + if n == 1 { "" } else { "s" }, + left = left, + n = n, + unsure = unsure + ), + ); + app.ui.last_find_missing = Some(v.clone()); + app.ui.last_find_missing_result = Some(Ok(v)); + } + Err(e) => { + app.toast(ctx, e.clone()); + app.ui.last_find_missing_result = Some(Err(e)); + } + } +} + +/// The `photo.locate` command. +fn photo_locate_command(app: &mut LightcraftApp, p: &Value) -> Result { + let Some(id) = app.session.active() else { return Err("no photo selected".into()) }; + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + match path { + Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), + None => Ok(Value::Null), + } +} + +/// The `file.findMissing` command. +fn file_find_missing_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Result { + let within = find_missing_within(p)?; + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let timeout = find_missing_timeout(p)?; + let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; + let folder = match p.get("folder").and_then(Value::as_str) { + Some(f) => Some(f.to_string()), + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, + }; + let Some(folder) = folder else { return Ok(Value::Null) }; + // the search (checking every photo's file, walking the folder) runs on a worker thread; + // the relinking happens back here, as one undo step + const LABEL: &str = "Find Missing Photos"; + if app.tasks.is_running(LABEL) { + return Err("Find Missing Photos is already searching".into()); + } + let mut candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); + if let Some(within) = within { + candidates.retain(|candidate| lightcraft_catalog::query::folder_within(&candidate.path, &within)); + } + #[cfg(test)] + let test_delay = app.ui.find_missing_test_delay; + let work = move || { + #[cfg(test)] + if let Some(d) = test_delay { + std::thread::sleep(d); + } + lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder) + }; + app.ui.last_find_missing = None; + app.ui.last_find_missing_result = None; + crate::tasks::spawn(app, LABEL, Some("findMissing"), work, find_missing_done)?; + if !wait { + return Ok(json!({"background": true})); + } + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + if !crate::tasks::wait_for(app, &ctx, LABEL, timeout) { + return Err("Find Missing Photos: search timed out (search continues in the background)".into()); + } + app.ui.last_find_missing_result.take().unwrap_or_else(|| Err("Find Missing Photos: worker terminated unexpectedly".into())) +} + +/// The `photo.tagFromTracklog` command. +fn photo_tag_from_tracklog_command(app: &mut LightcraftApp, _ctx: &egui::Context, id: &str, p: &Value) -> Result { + // a GPX file → GPS for the selected photos by capture time (one undo step) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = p.as_object().cloned().unwrap_or_default(); + params.insert("path".into(), json!(path)); + let ask_zone = !params.contains_key("offset"); + let params = Value::Object(params); + if ask_zone { + // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here + crate::panels::dialogs::prompt( + app, + "Auto-Tag from Tracklog", + "Camera time zone, e.g. -07:00 (empty: UTC)", + "", + "photo.tagFromTracklog", + params, + "offset", + ); + return Ok(Value::Null); + } + let r = app.run("photo.autoTagTracklog", params); + if let Ok(v) = &r { + let n = v["tagged"].as_u64().unwrap_or(0); + let sk = &v["skipped"]; + let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); + let outside = sk["outside"].as_u64().unwrap_or(0); + if outside > 0 { + msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); + } + let kept = sk["hasGps"].as_u64().unwrap_or(0); + if kept > 0 { + msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); + } + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + app.toast(&ctx, msg); + } + r +} + +/// The files a command names in `paths`, else the ones picked in a dialog. `Err` is the command's own answer when +/// there is nothing to import (the dialog is still open or was cancelled, or none is available). +fn paths_or_pick( + app: &mut LightcraftApp, + id: &str, + p: &Value, + request: PickRequest, + now: impl FnOnce(&mut crate::services::Services) -> Option>, +) -> Result, Result> { + let paths: Vec = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => match crate::pick::ask(app, id, p, "paths", request, now) { + Picked::Now(paths) => paths, + Picked::Later => return Err(Ok(Value::Null)), + Picked::Unavailable => return Err(Err("no file dialog on this platform".into())), + }, + }; + if paths.is_empty() { + return Err(Ok(Value::Null)); + } + Ok(paths) +} + +/// What an import of presets and profiles has to say: one toast for both halves (a second toast would replace the first). +#[derive(Default)] +struct ImportNews { + parts: Vec, + /// `name: reason` for each file that couldn't be imported + problems: Vec, +} + +/// `name: reason` for each `[name, reason]` of a `failed` list. +fn failed_files(v: &Value) -> impl Iterator + '_ { + v["failed"].as_array().into_iter().flatten().map(|f| { + let name = f.get(0).and_then(Value::as_str).unwrap_or_default(); + let reason = f.get(1).and_then(Value::as_str).unwrap_or_default(); + format!("{name}: {reason}") + }) +} + +/// Import the `.cube` LUTs (and those inside zips / folders) as profiles; the answer to report back. +fn import_luts(app: &mut LightcraftApp, lut_paths: &[&String], news: &mut ImportNews) -> Value { + match app.session.execute("profile.import", &json!({"paths": lut_paths})) { + Ok(v) => { + let profiles = v["imported"].as_array().map_or(0, Vec::len); + if profiles > 0 { + news.parts.push(crate::i18n::tr_format!( + "Imported {profiles} profile{} (Profile browser ▸ their groups)", + crate::i18n::plural_s(profiles), + profiles = profiles + )); + } + news.problems.extend(failed_files(&v)); + v + } + Err(e) => { + news.problems.push(e.to_string()); + json!({"error": e.to_string()}) + } + } +} + +/// The settings of imported presets that have no counterpart here (the other editor's profiles, masks…), as a note. +fn not_carried_over(v: &Value) -> String { + let mut skipped: Vec<&str> = v["imported"] + .as_array() + .into_iter() + .flatten() + .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + skipped.sort_unstable(); + skipped.dedup(); + if skipped.is_empty() { + return String::new(); + } + let names: Vec<&str> = skipped.iter().take(3).copied().collect(); + crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }) +} + +/// The sentence for the presets of an import (none when the other half already says what happened). +fn presets_sentence(v: &Value, profile_news: bool) -> Option { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let msg = match (n, failed) { + // a zip of LUTs (or only local presets): the other half already says what happened + (0, 0) if profile_news => return None, + (0, 0) => "No new presets".to_string(), + (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", crate::i18n::plural_s(n), n = n), + (n, f) => crate::i18n::tr_format!( + "Imported {n} preset{}, {f} file{} not readable", + crate::i18n::plural_s(n), + crate::i18n::plural_s(f), + f = f, + n = n + ), + }; + Some(msg + ¬_carried_over(v)) +} + +/// Import the preset files; adds to what `news` reports. +fn import_preset_files(app: &mut LightcraftApp, preset_paths: &[&String], news: &mut ImportNews) -> Result { + if preset_paths.is_empty() { + return Ok(json!({"imported": [], "failed": [], "skipped": 0})); + } + let r = app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast + if v["effects"].as_array().is_some_and(|e| !e.is_empty()) { + news.parts.push(crate::panels::mask_effects::import_toast(v)); + } + let profile_news = !news.parts.is_empty() || !news.problems.is_empty(); + news.parts.extend(presets_sentence(v, profile_news)); + if v["imported"].as_array().is_some_and(|i| !i.is_empty()) { + app.ui.presets = true; + } + } + r +} + +/// Show what an import did: errors if any file failed, else the parts. +fn toast_import_news(app: &mut LightcraftApp, ctx: &egui::Context, news: &ImportNews) { + let mut msg = news.parts.join(" · "); + if news.problems.is_empty() { + if !msg.is_empty() { + app.toast(ctx, msg); + } + return; + } + let more = if news.problems.len() > 2 { "…" } else { "" }; + let detail = format!("{}{more}", news.problems.iter().take(2).cloned().collect::>().join("; ")); + if msg.is_empty() { + msg = crate::i18n::tr_format!("Import failed: {e}", e = detail); + } else { + msg += &crate::i18n::tr_format!(" · {} failed: {}", news.problems.len(), detail); + } + app.toast_error(ctx, msg); +} + +/// The `file.importPresets` command. +fn file_import_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let request = + PickRequest::files(Some(crate::i18n::tr("Import Presets").into()), crate::i18n::tr("Presets & Profiles"), crate::pick::PRESET_EXTENSIONS); + let paths = match paths_or_pick(app, id, p, request, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Ok(paths) => paths, + Err(answer) => return answer, + }; + let lut_paths: Vec<&String> = paths + .iter() + .filter(|p| { + let l = p.to_ascii_lowercase(); + l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() + }) + .collect(); + let mut news = ImportNews::default(); + let profiles = if lut_paths.is_empty() { Value::Null } else { import_luts(app, &lut_paths, &mut news) }; + let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); + let r = import_preset_files(app, &preset_paths, &mut news); + toast_import_news(app, ctx, &news); + r.map(|mut v| { + if !lut_paths.is_empty() + && let Some(o) = v.as_object_mut() + { + o.insert("profiles".into(), profiles); + } + v + }) +} + +/// The `file.importKeywords` command. +fn file_import_keywords_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); + match &r { + Ok(v) => { + let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); + let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); + if updated > 0 { + msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); + } + app.toast(ctx, msg); + } + Err(e) => app.toast(ctx, e.clone()), + } + r +} + +/// The `file.exportKeywords` command. +fn file_export_keywords_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let name = "Keywords.txt"; + let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); + if let Err(e) = &r { + app.toast(ctx, e.clone()); + } + if let Ok(v) = &r { + let n = v["keywords"].as_u64().unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); + // Capture One's importer refuses ; , < > in a list: say which to rename first + let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !refuses.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — Capture One won't import a list with ; , < or > in a keyword: {names}", + names = refuses.iter().take(5).copied().collect::>().join(" · ") + )); + } + // what the format can't hold (a name in brackets, a line break…) isn't in the file + let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !left.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — left out, as a keyword list can't hold them: {names}", + // escaped: a line break in a name would break the toast + names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::>().join(" · ") + )); + } + app.toast(ctx, msg); + } + r +} + +/// The `file.exportMaskEffects` command. +fn file_export_mask_effects_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); + let req = PickRequest::save( + crate::i18n::tr("Export Effect Presets"), + crate::i18n::tr("LightCraft Effect Presets"), + &["lceffect"], + name.clone(), + ); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = json!({"path": path}); + for k in ["names", "group"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); + match &r { + Ok(v) => app.toast(ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), + Err(e) => app.toast_error(ctx, e.clone()), + } + r +} + +/// The `file.importCurvePresets` command. +fn file_import_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let request = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + let paths = match paths_or_pick(app, id, p, request, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Ok(paths) => paths, + Err(answer) => return answer, + }; + let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(ctx, curve_import_message(v)); + } + r +} + +/// What an import of point curve presets did. +fn curve_import_message(v: &Value) -> String { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let imported = crate::i18n::tr_format!("Imported {n} point curve preset{}", crate::i18n::plural_s(n), n = n); + if failed == 0 { + return imported; + } + imported + &crate::i18n::tr_format!(", {failed} file{} not readable", crate::i18n::plural_s(failed), failed = failed) +} + +/// The `file.exportCurvePresets` command. +fn file_export_curve_presets_command(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::save( + crate::i18n::tr("Export Point Curve Presets"), + crate::i18n::tr("Point Curve Presets"), + &["lccurve"], + "Point Curves.lccurve", + ); + match crate::pick::ask(app, id, p, "path", req, |s| { + s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no file dialog on this platform".into()), + } + } + }; + let Some(path) = path else { return Ok(Value::Null) }; + let mut params = json!({"path": path}); + if let Some(n) = p.get("names") { + params["names"] = n.clone(); + } + let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + r +} diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 7236b5528..2759d2197 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1,4 +1,4 @@ -use super::*; +use super::UiCommand; /// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language /// has a matching menu command (also listed through the control channel). Tests check coverage. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4c2bed935..ed9c45fb5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -46,21 +46,26 @@ pub use crate::dialogs::settings::slide_templates; pub use crate::dialogs::settings::slide_titles; use crate::theme::Tokens; +/// A side panel the user resizes: which side, how it is drawn and how wide it may be. +pub struct SidePanel<'a> { + /// the left side (else the right) + pub left: bool, + pub id: &'a str, + pub frame: egui::Frame, + /// the width the user chose + pub width: f32, + pub limits: crate::state::PanelWidth, + /// room to leave for what is beside the photo + pub reserve: f32, +} + /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], /// so it survives navigation and restarts) is the source of truth: egui's own remembered size is /// dropped every frame, and a drag of the edge returns the new width to store. The panel never /// takes more than `available − reserve − MIN_PHOTO_WIDTH` (its minimum permitting), so the photo /// stays usable; a narrower window shrinks it without forgetting the chosen width. -pub fn resizable_side( - ui: &mut egui::Ui, - left: bool, - id: &str, - frame: egui::Frame, - width: f32, - limits: crate::state::PanelWidth, - reserve: f32, - add: impl FnOnce(&mut egui::Ui), -) -> Option { +pub fn resizable_side(ui: &mut egui::Ui, side: SidePanel, add: impl FnOnce(&mut egui::Ui)) -> Option { + let SidePanel { left, id, frame, width, limits, reserve } = side; // `Panel::new` stores `IdSalt::new(id)`; the panel's persisted key is // `parent_ui.scope_id().with_salt(that_salt)`, which is exactly `scope_id().with(id)`. // (Passing an already-built `Id` through `Panel::left` would salt it a second time.) diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index c18b3ad72..42640503e 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -29,6 +29,31 @@ fn chip_width(ui: &egui::Ui, label: &str, font: egui::FontId) -> f32 { ui.painter().layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x + 12.0 + 22.0 } +/// The colour labels a label chip names, in words ("Red or Blue"). +fn label_names(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { + use crate::i18n::tr; + 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::>().join(&format!(" {} ", tr("or"))); + if !shown.is_empty() { + return or(&shown); + } + 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")) +} + +/// The value of a `prefix: value` chip, localised. +fn prefixed_value(prefix: &str, value: &str, filter: &lightcraft_catalog::Filter) -> String { + use crate::i18n::{date_group_label, tr}; + let date = match prefix { + "Flag" | "Type" => return tr(value).to_string(), + "Date" => &filter.date, + "Imported" => &filter.imported, + _ => return value.to_string(), + }; + date.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) +} + /// Localise generated chip labels without rewriting search text or metadata values. pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { use crate::i18n::{date_group_label, rules_label, tr}; @@ -41,16 +66,7 @@ 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 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::>().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")); + return format!("{}: {}", tr("Label"), label_names(filter, catalog)); } if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() { return match (&filter.date_from, &filter.date_to) { @@ -64,16 +80,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = if filter.only_none { 0 } else { filter.only.len() })); } if let Some((prefix, value)) = chip.label.split_once(": ") { - let value = if matches!(prefix, "Flag" | "Type") { - tr(value).to_string() - } else if prefix == "Date" { - filter.date.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) - } else if prefix == "Imported" { - filter.imported.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false)) - } else { - value.to_string() - }; - return format!("{}: {value}", tr(prefix)); + return format!("{}: {}", tr(prefix), prefixed_value(prefix, value, filter)); } if let Some(value) = chip.label.strip_prefix("Rating ") { return format!("{} {value}", tr("Rating")); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 829500da4..9b9a23673 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -197,6 +197,17 @@ fn area_and_filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui) -> Rect { canvas } +/// Ask for the render of a tile (about `want` pixels on its long edge) and for the thumbnail shown meanwhile. +fn request_tile_renders(app: &mut LightcraftApp, id: PhotoId, slot: Slot, want: usize, aspect: f32) { + let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { + app.renderer.request(slot, job, 60); + } + if let Some(job) = app.session.thumb_job(id, 256) { + app.renderer.request(Slot::Thumb(id), job, 30); + } +} + /// Draw one photo fitted into `area` (rendered at its display size into `slot`), with its caption /// strip below. Returns the image rect and the click/drag response. fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slot, area: Rect, label: &str, zoom: Zoom) -> (Rect, egui::Response) { @@ -230,13 +241,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let texture_side = super::detail::texture_side(ui.ctx()); let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0_f32.min(texture_side as f32)) as usize; - let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; - if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { - app.renderer.request(slot, job, 60); - } - if let Some(job) = app.session.thumb_job(id, 256) { - app.renderer.request(Slot::Thumb(id), job, 30); - } + request_tile_renders(app, id, slot, want, aspect); // zoomed past what that render holds (1:1 to check focus): the part on screen as a window, as // in the loupe. Compare and Reference only; Survey tiles are fit views. let pane = match slot { diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index fdb208b51..b72df10b2 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -319,6 +319,13 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V if status["enabled"] != true { return; } + speed_section(app, ui, t, list, &status); + photos_section(app, ui, t, list, &status); + cache_section(app, ui, t, list, &status); +} + +/// Where the work runs. +fn speed_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Speed"); let current = list["runOn"].as_str().unwrap_or("auto").to_string(); let mut run_on = current.as_str(); @@ -342,6 +349,10 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V app.caches.denoise.epoch += 1; } } +} + +/// Whether photos are made as they are looked at, and the progress of what is being made. +fn photos_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Photos"); let mut auto = list["auto"].as_bool().unwrap_or(true); if check(ui, "settings.denoiseAuto", &mut auto, "Make the denoised picture of the photos I am looking at") { @@ -350,27 +361,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } hint(ui, t, "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing."); let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued); - match running { - Some((done, total)) => { - let name = status["running"]["photo"] - .as_u64() - .and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())) - .unwrap_or_default(); - let text = tr_format!( - "Denoising {name}: {done} of {total} tiles{}", - if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() }, - name = name, - done = done, - total = total - ); - settings_bar(ui, done, total, text); - } - None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), - None => { - let ready = status["ready"].as_u64().unwrap_or(0); - hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture.")) - } - } + progress_line(app, ui, t, status, running, queued); ui.horizontal(|ui| { let r = ui.button(tr("Denoise all photos with an Amount")); register(ui, "denoise:queueAll", r.rect); @@ -385,6 +376,37 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } } }); +} + +/// "Denoising {photo}: {done} of {total} tiles", and how many photos wait. +fn running_text(app: &LightcraftApp, status: &Value, done: u64, total: u64, queued: u64) -> String { + let name = + status["running"]["photo"].as_u64().and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())).unwrap_or_default(); + tr_format!("Denoising {name}: {done} of {total} tiles{}", waiting_suffix(queued), name = name, done = done, total = total) +} + +/// " · N waiting" after the running photo, when any wait. +fn waiting_suffix(queued: u64) -> String { + if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() } +} + +/// What is being made now, or how many photos wait or are done. +fn progress_line(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, status: &Value, running: Option<(u64, u64)>, queued: u64) { + match running { + Some((done, total)) => { + let text = running_text(app, status, done, total, queued); + settings_bar(ui, done, total, text); + } + None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), + None => { + let ready = status["ready"].as_u64().unwrap_or(0); + hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture.")) + } + } +} + +/// The cache's contents and limit. +fn cache_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Cache"); let (files, bytes) = (status["cache"]["files"].as_u64().unwrap_or(0), status["cache"]["bytes"].as_u64().unwrap_or(0)); hint( diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index eee9d7d0c..6840afdf0 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -46,7 +46,7 @@ use info::{ preview_only_pill, quick_name, }; pub(crate) use info::{draw_focus_point, failed_pill, loupe_pill, rendering_overdue}; -use masks::{RegionEdit, color_range_pick, mask_overlay, region_overlay}; +use masks::{RegionContext, RegionEdit, color_range_pick, mask_overlay, region_overlay}; use retouch::{eye_overlay, remove_overlay}; pub(crate) use window::overlay_supports_window; use window::{WindowCtx, WindowView, draw_window, request_window}; @@ -138,11 +138,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { - if super::empty_library::shown(&app.session) { - super::empty_library::show(app, ui, canvas); - } else { - super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); - } + show_no_photo(app, ui, canvas); return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; @@ -154,47 +150,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref()); let aspect = frame.aspect() as f32; let ppp = ui.ctx().pixels_per_point(); - let area = canvas.shrink(if fullscreen { - 0.0 - } else if crop_tool { - 48.0 - } else { - 24.0 - }); + let area = canvas.shrink(loupe_margin(fullscreen, crop_tool)); let native = output_px(&frame); let texture_side = texture_side(ui.ctx()); // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom); - let areas: Vec = match app.ui.before_after { - BeforeAfter::SideBySide => { - let half = area.width() / 2.0 - 6.0; - vec![ - Rect::from_min_size(area.min, vec2(half, area.height())), - Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())), - ] - } - BeforeAfter::TopBottom => { - let half = area.height() / 2.0 - 14.0; - vec![ - Rect::from_min_size(area.min, vec2(area.width(), half)), - Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)), - ] - } - _ => vec![area], - }; + let areas = view_areas(app.ui.before_after, area); let main_area = *areas.last().unwrap_or(&area); - // A previous crop/rotation can stay visible while its replacement renders. Its pixels - // must not override the current frame geometry, even when it is the same photo. - let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(&d), !crop_tool)); - let visible_texture_aspect = app - .renderer - .textures - .get(&Slot::Main) - .filter(current_texture) - .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture)) - .or_else(|| app.renderer.thumb(id).filter(current_texture)) - .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32); + let visible_texture_aspect = visible_texture_aspect(app, id, &d, crop_tool); // Follow the texture only where it really differs (an unsupported raw's embedded JPEG with // another crop): a texture's whole-pixel size is a little off the exact aspect, and the crop // tool's overlays need the frame's own geometry. @@ -204,15 +168,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); let in_pane = |pane: Rect, image: Rect| image.translate(pane.center() - main_area.center()); - let mut navigating = false; - for &view_area in &areas { + let navigating = areas.iter().any(|&view_area| { let view_img = in_pane(view_area, img_rect); - 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; - break; - } + navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) + }); + if navigating { + target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); + img_rect = target_rect; } let before_img = in_pane(areas[0], img_rect); let before_target = in_pane(areas[0], target_rect); @@ -222,14 +184,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let overlay = view_overlay(app, &d); // 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 && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), - overlay, - ) - && lightcraft_engine::gpu::ready() - && lightcraft_engine::gpu::available(); + let gpu = interacting && gpu_draws_view(app, overlay); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); let now = ui.input(|i| i.time); @@ -250,10 +205,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { draft, windows: overlay_supports_window(overlay), }; - let mut plan = crate::region::plan(&app.ui.settings, sizes); - if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { - plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); - } + let plan = loupe_plan(app, id, look, sizes); // time the full-size drafts where they are what shows: under a window the whole-frame draft // is small anyway, and the frame's time is the window's upload if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) { @@ -271,14 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near // the output's limit) and shrunk once, by the Lanczos display fit below. // (zoomed in under a window render, the whole frame is hidden: it stays canvas-sized) - let want = if interacting || plan.window_edge.is_some() { - plan.main_edge - } else { - // (a user's explicit Preview size below the preview level still caps it) - let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; - let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); - plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) - }; + let want = whole_frame_edge(app, interacting, &plan, native_long, texture_side); // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along // one axis only. A box that matches the source to rounding is rendered at exactly its size. @@ -287,72 +232,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); let (sw, sh) = if aspect >= 1.0 { (se, short(se, aspect)) } else { (short(se, 1.0 / aspect), se) }; - if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { - let job = if interacting { job.draft() } else { job }; - // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on - // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap - // sampling (the render is up to a 64 px step larger, or the preview level) - let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; - let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); - let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); - app.renderer.request(Slot::Main, job, 100); - // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed - // in, what changes on screen is the window's draft) - #[cfg(not(target_arch = "wasm32"))] - if interacting && plan.window_edge.is_none() { - let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); - // (a frame time that is not a number waits for nothing) - let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); - app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); - } - } + let ask = LoupeAsk { id, with_crop: !crop_tool, interacting, fit_px, overlay }; + request_main(app, ui, &ask, [rw, rh], plan.window_edge.is_none()); // hovering a preset or profile: the photo with that look, shown instead of the loupe render // once it is ready (nothing is committed) - let hover_key = match app.hover_preview.clone() { - Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| { - // (the same display fit as the After view, drawn texel for texel like it) - let job = job.with_display_fit(fit_px[0], fit_px[1]); - let key = job.key; - app.renderer.request(Slot::Hover, job, 105); - (key, h.label) - }), - _ => None, - }; - // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and - // kept, view render cached) so stepping to them is instant - if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { - let ids = app.session.visible_cloned(); - if let Some(i) = ids.iter().position(|p| *p == id) { - let next = ids.get(i + 1).copied(); - let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); - for (n, nid) in [next, prev].into_iter().enumerate() { - let Some(nid) = nid else { continue }; - let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; - let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); - let na = nf.aspect() as f32; - let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); - let nw = - app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side); - let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; - if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { - app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); - } - } - } + let hover_key = request_hover(app, &ask, [sw, sh]); + if !interacting { + prefetch_neighbours(app, id, main_area, ppp, texture_side, !crop_tool); } // until the loupe has this photo: show its cached render / embedded preview / a thumbnail - if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) - && let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool) - { - app.renderer.request_quick(Slot::Preview, q, 110); - } + request_stand_in(app, id, want, !crop_tool); let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash)); - if (split || show_before || split_view) - && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) - { - // (the same display fit as the After view, so both panes are the same texture size) - let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; - app.renderer.request(Slot::Before, job, 90); + if split || show_before || split_view { + request_before(app, &ask, [sw, sh]); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more // than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays and @@ -371,63 +263,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { look, holding: navigating || app.size_hold.holding(now), }; - // (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show) - let mut window_views: Vec = Vec::new(); - if window_ctx.frame_edge.is_some() { - let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area }; - if split { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: before_img, target: before_target, visible: areas[0] }); - window_views.push(after); - } else if show_before { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); - } else if split_view { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); - window_views.push(WindowView { visible: canvas, ..after }); - } else { - window_views.push(WindowView { visible: canvas, ..after }); - } - } - let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new(); - for v in window_views { - if let Some(view) = request_window(app, &window_ctx, &v) { - windows.push((v, view)); - } - } - for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] { - let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view); - if before { - app.region_before_view = shown; - } else { - app.region_view = shown; - } - if shown.is_none() { - app.region_tiles.retain(|(b, _), _| *b != before); - app.renderer.release(slot); - } - } - let p = ui.painter_at(canvas); - // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame - // between photos, and the full render replaces them in place) - let draw = |slot: Slot, r: Rect, clip: Rect| -> &'static str { - let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); - let (tex, what) = if let Some(t) = mine(slot) { - (t, "render") - } else if let Some(t) = mine(Slot::Preview) { - (t, t.quick.map(quick_name).unwrap_or("preview")) - } else if let Some(t) = app.renderer.thumb(id) { - (t, "thumb") - } else { - return "none"; - }; - let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); - p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - what + let window_views = if window_ctx.frame_edge.is_some() { + let mode = ViewMode { split, show_before, split_view }; + window_views(mode, &Views { img_rect, target_rect, before_img, before_target, areas: &areas, main_area, canvas }) + } else { + Vec::new() }; + let windows = request_windows(app, &window_ctx, window_views); + let p = ui.painter_at(canvas); + let draw = |slot: Slot, r: Rect, clip: Rect| draw_slot(app, &p, id, ppp, slot, r, clip); // soft proofing: a paper-white surround and the proof's name, as Lightroom shows it if app.ui.soft_proof && !fullscreen { - p.rect_filled(canvas, 0.0, Color32::from_gray(238)); - let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label()); - p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); + proof_surround(app, &p, &t, canvas); } let shown; // the Before label's pill, painted once the window renders are down (they would cover it) @@ -441,10 +288,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let br = before_img; draw(Slot::Before, br, areas[0]); shown = draw(Slot::Main, img_rect, main_area); - // the labels under each photo, or under its pane when the photo reaches past it - let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); - p.text(label_at(br, areas[0]), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); - p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); + split_labels(&p, &t, [(br, areas[0]), (img_rect, main_area)]); } else if show_before { shown = draw(Slot::Before, img_rect, canvas); // on the photo itself, so it gets the hover label's dark pill to stay readable over light @@ -454,37 +298,136 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) { - let hover_rect = fit_texture_rect(img_rect, tex.size); - p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); - let bg = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); - p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); - p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); + draw_hover(&p, &t, tex, label, [img_rect, canvas], badge_top_inset); shown = "hover"; } else { shown = draw(Slot::Main, img_rect, canvas); if shown == "none" { - match app.renderer.failure_of(Slot::Main, id) { - Some(e) => { - p.text( - canvas.center() - vec2(0.0, 10.0), - Align2::CENTER_CENTER, - crate::i18n::tr("This photo can't be opened"), - t.semibold(14.0), - t.text, - ); - p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); - } - None if app.ui.settings.loupe_messages => { - p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); - } - None => {} - } + none_message(app, &p, &t, canvas, id); } } app.loupe_shown = Some((id, shown)); - // a wipe shows the Before whole-frame render on its side of the line… let line = crate::region::wipe_line(img_rect, canvas); + draw_wipe_before(app, &p, id, img_rect, line); + // …and the window renders go over the whole-frame ones, all of them, where they belong in the + // frame (clipped to their pane, or their side of the line) + for (v, view) in &windows { + // (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then) + let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after }; + let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect); + draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); + } + if let Some((bg, g)) = before_badge { + paint_badge(&p, bg, g); + register(ui, "canvas:beforeLabel", bg); + } + // the line of a wipe over both + draw_wipe_line(app.ui.before_after, &p, img_rect, line); + let map = CanvasMap::new(&frame, img_rect); + let over = OverPhoto { photo: &photo, d: &d, map: &map, canvas, img_rect, fullscreen, crop_tool, clipping: !show_before }; + photo_overlays(app, ui, &p, &over); + // Cursor selection must also run on gesture frames, when tool input is suppressed. + if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) { + general_cursor(app, ui, &resp, img_rect, main_area); + } + if !navigating { + let tv = ToolView { right, map: &map, frame: &frame, d: &d, id, split, native, aspect }; + tool_interaction(app, ui, &resp, &tv, &Views { img_rect, target_rect, before_img, before_target, areas: &areas, main_area, canvas }); + } + // drawn and hit-tested above the loupe and its tools: clicks on it pan + let nav = navigator(app, ui, canvas, img_rect, id); + // what is on screen isn't the render on its way (a stand-in, the last render after an edit, + // a magnified frame under its window): say so once that has lasted a moment + let coming = render_coming(app, shown, &windows, ViewMode { split, show_before, split_view }); + let keys = + [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); + let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::unique("loupe-rendering"), None), keys, coming, now)); + // the render failed while a stand-in is up: what is on screen is all there will be (also while + // a library change retries it) + let failed = failed_render(app, id, !rendering && (!show_before || split), shown); + if rendering || failed.is_some() { + let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; + let over = if over.is_positive() { over } else { canvas }; + status_pill(ui, app, &photo, [canvas, over], nav, failed); + } + if !fullscreen { + photo.preview_only.as_deref().inspect(|why| preview_only_pill(ui, canvas, why)); + } + loupe_context_menu(app, &resp, id); +} + +/// The loupe's right-click menu: zoom commands and the grid's photo menu. +fn loupe_context_menu(app: &mut LightcraftApp, resp: &egui::Response, id: PhotoId) { + crate::widgets::context_menu(resp, |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { + for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { + if ui.button(crate::i18n::tr(label)).clicked() { + let _ = app.run(cmd, json!({})); + } + } + }); + ui.separator(); + super::grid::context_menu(app, ui, id); + }); +} + +/// A fine grid while a transform (geometry) slider is dragged, to judge verticals. +fn transform_grid(app: &LightcraftApp, p: &egui::Painter, img_rect: Rect) { + if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { + let n = 12; + let stroke = Stroke::new(1.0, Color32::from_white_alpha(70)); + for i in 1..n { + let fx = img_rect.left() + img_rect.width() * i as f32 / n as f32; + p.line_segment([pos2(fx, img_rect.top()), pos2(fx, img_rect.bottom())], stroke); + } + let rows = ((n as f32) * img_rect.height() / img_rect.width()).round().max(2.0) as usize; + for i in 1..rows { + let fy = img_rect.top() + img_rect.height() * i as f32 / rows as f32; + p.line_segment([pos2(img_rect.left(), fy), pos2(img_rect.right(), fy)], stroke); + } + } +} + +/// The photo's face boxes and the name box on them. +fn face_boxes( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + d: &DevelopSettings, +) { + let id = photo.id; + // a name box left open on another photo is dropped + if app.ui.name_edit.as_ref().is_some_and(|e| e.photo != id.0) { + app.ui.name_edit = None; + } + let hints = super::faces::hints_for(app, id.0); + let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; + // while the name box is open and recognition is not set up, the box offers the next step + let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; + let regions = RegionContext { photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; + match region_overlay(ui, p, map, ®ions, &mut app.ui.name_edit) { + Some(RegionEdit::SetUp) => { + app.ui.name_edit = None; + super::faces::take_step(app, ui.ctx(), setup); + } + Some(RegionEdit::Remove(index)) => { + let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); + } + Some(RegionEdit::Name(index, name)) => { + app.ui.name_edit = None; + let _ = app.run("faces.setName", json!({"id": id.0, "index": index, "name": name})); + } + Some(RegionEdit::Resize(index, r)) => { + let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}})); + } + None => {} + } +} + +/// A wipe shows the Before whole-frame render on its side of the line. +fn draw_wipe_before(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, img_rect: Rect, line: Pos2) { if app.ui.before_after == BeforeAfter::Split && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { @@ -501,156 +444,437 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.top() + v * before_rect.height())); p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, v)), Color32::WHITE); } - // …and the window renders go over the whole-frame ones, all of them, where they belong in the - // frame (clipped to their pane, or their side of the line) - for (v, view) in &windows { - // (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then) - let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after }; - let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect); - draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); +} + +/// Once a photo is on screen, prepare its neighbours in filmstrip order (source decoded and kept, +/// view render cached) so stepping to them is instant. +fn prefetch_neighbours(app: &mut LightcraftApp, id: PhotoId, main_area: Rect, ppp: f32, texture_side: usize, with_crop: bool) { + // only once this photo is on screen + if app.renderer.is_pending(Slot::Main) || app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) { + return; } - if let Some((bg, g)) = before_badge { - p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); - p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); - register(ui, "canvas:beforeLabel", bg); - } - // the line of a wipe over both - if app.ui.before_after == BeforeAfter::Split { - p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); - } - if app.ui.before_after == BeforeAfter::SplitTopBottom { - p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], Stroke::new(1.5, Color32::WHITE)); - } - if app.ui.show_clipping && !show_before && !fullscreen { - clipping_overlay(app, &p, img_rect); - } - // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) - super::layout_overlay::draw(app, &p, img_rect); - register(ui, "canvas:image", img_rect); - let map = CanvasMap::new(&frame, img_rect); - info_overlay(app, &p, canvas, &photo); - if !fullscreen { - filter_pill(app, ui, canvas); - } - if app.ui.face_boxes { - // a name box left open on another photo is dropped - if app.ui.name_edit.as_ref().is_some_and(|e| e.photo != id.0) { - app.ui.name_edit = None; - } - let hints = super::faces::hints_for(app, id.0); - let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; - // while the name box is open and recognition is not set up, the box offers the next step - let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; - match region_overlay(ui, &p, &map, &photo, d.orientation, hints.as_deref(), &people, &mut app.ui.name_edit, setup) { - Some(RegionEdit::SetUp) => { - app.ui.name_edit = None; - super::faces::take_step(app, ui.ctx(), setup); - } - Some(RegionEdit::Remove(index)) => { - let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); - } - Some(RegionEdit::Name(index, name)) => { - app.ui.name_edit = None; - let _ = app.run("faces.setName", json!({"id": id.0, "index": index, "name": name})); - } - Some(RegionEdit::Resize(index, r)) => { - let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}})); - } - None => {} - } - } - if app.ui.settings.focus_point && !crop_tool { - draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint"); - } - // a fine grid while a transform (geometry) slider is dragged, to judge verticals - if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { - let n = 12; - let stroke = Stroke::new(1.0, Color32::from_white_alpha(70)); - for i in 1..n { - let fx = img_rect.left() + img_rect.width() * i as f32 / n as f32; - p.line_segment([pos2(fx, img_rect.top()), pos2(fx, img_rect.bottom())], stroke); - } - let rows = ((n as f32) * img_rect.height() / img_rect.width()).round().max(2.0) as usize; - for i in 1..rows { - let fy = img_rect.top() + img_rect.height() * i as f32 / rows as f32; - p.line_segment([pos2(img_rect.left(), fy), pos2(img_rect.right(), fy)], stroke); - } - } - // Cursor selection must also run on gesture frames, when tool input is suppressed. - if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) { - general_cursor(app, ui, &resp, img_rect, main_area); - } - if !navigating { - match right { - RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), - RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), - RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), - RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), - _ => { - // side by side / stacked: Before shows the same frame, so a click in its view (white - // balance, colour samples, targeted adjustment) means the same point of the photo. - // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. - // A click-zoom there centres both views on the point clicked (#1742). - let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - let (map, img, pane) = match (split, areas.first(), pointer) { - (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), - _ => (map, img_rect, main_area), - }; - general_interaction(app, ui, &resp, &map, img, pane, native, aspect) + let ids = app.session.visible_cloned(); + if let Some(i) = ids.iter().position(|p| *p == id) { + let next = ids.get(i + 1).copied(); + let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); + for (n, nid) in [next, prev].into_iter().enumerate() { + let Some(nid) = nid else { continue }; + let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; + let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, with_crop, np.embedded_lens.as_ref()); + let na = nf.aspect() as f32; + let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); + let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side); + let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; + if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), with_crop) { + app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); } } } - // drawn and hit-tested above the loupe and its tools: clicks on it pan - let nav = navigator(app, ui, canvas, img_rect, id); - // what is on screen isn't the render on its way (a stand-in, the last render after an edit, - // a magnified frame under its window): say so once that has lasted a moment - let whole: &[Slot] = if show_before && !split { +} + +/// The rects of the two views of a before/after (side by side or stacked), else the one area. +fn view_areas(mode: BeforeAfter, area: Rect) -> Vec { + match mode { + BeforeAfter::SideBySide => { + let half = area.width() / 2.0 - 6.0; + vec![ + Rect::from_min_size(area.min, vec2(half, area.height())), + Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())), + ] + } + BeforeAfter::TopBottom => { + let half = area.height() / 2.0 - 14.0; + vec![ + Rect::from_min_size(area.min, vec2(area.width(), half)), + Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)), + ] + } + _ => vec![area], + } +} + +/// Which before/after layout is up. +#[derive(Clone, Copy)] +struct ViewMode { + split: bool, + show_before: bool, + split_view: bool, +} + +/// The rects the loupe's views are drawn in. +struct Views<'a> { + img_rect: Rect, + target_rect: Rect, + before_img: Rect, + before_target: Rect, + areas: &'a [Rect], + main_area: Rect, + canvas: Rect, +} + +/// The window renders wanted for the layout: (slot, before side?, rect to draw in, rect it is +/// requested for, area whose pixels show). +fn window_views(mode: ViewMode, v: &Views) -> Vec { + let mut out: Vec = Vec::new(); + let after = WindowView { slot: Slot::Region, before: false, img: v.img_rect, target: v.target_rect, visible: v.main_area }; + if mode.split { + out.push(WindowView { + slot: Slot::RegionBefore, + before: true, + img: v.before_img, + target: v.before_target, + visible: v.areas.first().copied().unwrap_or(v.canvas), + }); + out.push(after); + } else if mode.show_before { + out.push(WindowView { slot: Slot::RegionBefore, before: true, img: v.img_rect, target: v.target_rect, visible: v.canvas }); + } else if mode.split_view { + out.push(WindowView { slot: Slot::RegionBefore, before: true, img: v.img_rect, target: v.target_rect, visible: v.canvas }); + out.push(WindowView { visible: v.canvas, ..after }); + } else { + out.push(WindowView { visible: v.canvas, ..after }); + } + out +} + +/// What the canvas says when nothing could be drawn: why the photo failed, or that it is rendering. +fn none_message(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, canvas: Rect, id: PhotoId) { + match app.renderer.failure_of(Slot::Main, id) { + Some(e) => { + p.text(canvas.center() - vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("This photo can't be opened"), t.semibold(14.0), t.text); + p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); + } + None if app.ui.settings.loupe_messages => { + p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); + } + None => {} + } +} + +/// The render failed while a stand-in is up: what is on screen is all there will be (also while a +/// library change retries it). +fn failed_render<'a>(app: &'a LightcraftApp, id: PhotoId, settled: bool, shown: &str) -> Option<&'a str> { + (settled && !matches!(shown, "render" | "none" | "hover") && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) + .then(|| app.renderer.failure_of(Slot::Main, id)) + .flatten() +} + +/// The pill over the photo (the After in two views; in the crop tool's wide margin, under it): the +/// failure, or View Options' "Show message when loading or rendering photos". +fn status_pill( + ui: &egui::Ui, + app: &LightcraftApp, + photo: &lightcraft_catalog::Photo, + [canvas, over]: [Rect; 2], + nav: Option, + failed: Option<&str>, +) { + match failed { + Some(e) => failed_pill(ui, app, photo, canvas, over, nav, "loupe:renderFailed", e), + None if app.ui.settings.loupe_messages => { + loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); + } + None => {} + } +} + +/// Soft proofing: a paper-white surround and the proof's name, as Lightroom shows it. +fn proof_surround(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, canvas: Rect) { + p.rect_filled(canvas, 0.0, Color32::from_gray(238)); + let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label()); + p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60)); +} + +/// Request the window renders, remember which sides have one, and release the slot of a side without. +fn request_windows(app: &mut LightcraftApp, ctx: &WindowCtx, views: Vec) -> Vec<(WindowView, crate::region::RegionView)> { + let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new(); + for v in views { + if let Some(view) = request_window(app, ctx, &v) { + windows.push((v, view)); + } + } + for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] { + let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view); + if before { + app.region_before_view = shown; + } else { + app.region_view = shown; + } + if shown.is_none() { + app.region_tiles.retain(|(b, _), _| *b != before); + app.renderer.release(slot); + } + } + windows +} + +/// The aspect of the texture on screen for this photo and geometry, if one is. +fn visible_texture_aspect(app: &LightcraftApp, id: PhotoId, d: &DevelopSettings, crop_tool: bool) -> Option { + // A previous crop/rotation can stay visible while its replacement renders. Its pixels + // must not override the current frame geometry, even when it is the same photo. + let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(d), !crop_tool)); + app.renderer + .textures + .get(&Slot::Main) + .filter(current_texture) + .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture)) + .or_else(|| app.renderer.thumb(id).filter(current_texture)) + .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32) +} + +/// Whether the GPU draws the view (by the GPU's own rule, `handles`; `ready` first: `available` +/// would wait for a device still being created). +fn gpu_draws_view(app: &LightcraftApp, overlay: lightcraft_pipeline::Overlay) -> bool { + 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() +} + +/// The line of a wipe. +fn draw_wipe_line(mode: BeforeAfter, p: &egui::Painter, img_rect: Rect, line: Pos2) { + let stroke = Stroke::new(1.5, Color32::WHITE); + match mode { + BeforeAfter::Split => { + p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], stroke); + } + BeforeAfter::SplitTopBottom => { + p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], stroke); + } + _ => {} + } +} + +/// Whether the render on its way is not what is on screen (a stand-in, the last render after an +/// edit, a magnified frame under its window). +fn render_coming(app: &LightcraftApp, shown: &str, windows: &[(WindowView, crate::region::RegionView)], mode: ViewMode) -> bool { + let whole: &[Slot] = if mode.show_before && !mode.split { &[Slot::Before] - } else if split || split_view { + } else if mode.split || mode.split_view { &[Slot::Main, Slot::Before] } else { &[Slot::Main] }; - let coming = !matches!(shown, "none" | "hover") - && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)); - let keys = - [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); - let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::unique("loupe-rendering"), None), keys, coming, now)); - // the render failed while a stand-in is up: what is on screen is all there will be (also while - // a library change retries it) - let failed = (!rendering - && (!show_before || split) - && !matches!(shown, "render" | "none" | "hover") - && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) - .then(|| app.renderer.failure_of(Slot::Main, id)) - .flatten(); - if rendering || failed.is_some() { - // over the photo (the After in two views); in the crop tool's wide margin, under it - let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; - let over = if over.is_positive() { over } else { canvas }; - match failed { - Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e), - // View Options' Show message when loading or rendering photos - None if app.ui.settings.loupe_messages => { - loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); - } - None => {} + !matches!(shown, "none" | "hover") + && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)) +} + +/// What the active tool needs to know about the loupe. +struct ToolView<'a> { + right: RightPanel, + map: &'a CanvasMap, + frame: &'a Frame, + d: &'a DevelopSettings, + id: PhotoId, + split: bool, + native: [usize; 2], + aspect: f32, +} + +/// Route the pointer to the right panel's tool on the photo, or to the general interactions. +fn tool_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, tv: &ToolView, v: &Views) { + let (map, d) = (tv.map, tv.d); + match tv.right { + RightPanel::Crop => crop_overlay(app, ui, resp, map, tv.frame, d, tv.id), + RightPanel::Masking => mask_overlay(app, ui, resp, map, d), + RightPanel::Remove => remove_overlay(app, ui, resp, map, d), + RightPanel::RedEye => eye_overlay(app, ui, resp, map, d), + _ => { + // side by side / stacked: Before shows the same frame, so a click in its view (white + // balance, colour samples, targeted adjustment) means the same point of the photo. + // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. + // A click-zoom there centres both views on the point clicked (#1742). + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + let (map, img, pane) = match (tv.split, v.areas.first(), pointer) { + (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(tv.frame, v.before_img), v.before_img, *before), + _ => (CanvasMap::new(tv.frame, v.img_rect), v.img_rect, v.main_area), + }; + general_interaction(app, ui, resp, &map, &PhotoPlacement { img, canvas: pane, native: tv.native, aspect: tv.aspect }) } } - if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { - preview_only_pill(ui, canvas, why); +} + +/// The loupe's render plan, without a window for a view the GPU already refused one for. +fn loupe_plan(app: &LightcraftApp, id: PhotoId, look: u64, sizes: crate::region::ViewSizes) -> crate::region::LoupePlan { + let plan = crate::region::plan(&app.ui.settings, sizes); + if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { + return crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); + } + plan +} + +fn whole_frame_edge(app: &LightcraftApp, interacting: bool, plan: &crate::region::LoupePlan, native_long: usize, texture_side: usize) -> usize { + if interacting || plan.window_edge.is_some() { + plan.main_edge + } else { + // (a user's explicit Preview size below the preview level still caps it) + let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; + let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); + plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) + } +} + +/// What a loupe render request is for. +struct LoupeAsk { + id: PhotoId, + with_crop: bool, + interacting: bool, + fit_px: [usize; 2], + overlay: lightcraft_pipeline::Overlay, +} + +/// Request the loupe's own render at `[rw, rh]` (drafts during drags). +fn request_main(app: &mut LightcraftApp, ui: &egui::Ui, a: &LoupeAsk, [rw, rh]: [usize; 2], whole_draft: bool) { + let (id, interacting, fit_px, overlay) = (a.id, a.interacting, a.fit_px, a.overlay); + if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), a.with_crop) { + let job = if interacting { job.draft() } else { job }; + // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on + // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap + // sampling (the render is up to a 64 px step larger, or the preview level) + let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; + let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); + let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); + app.renderer.request(Slot::Main, job, 100); + // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed + // in, what changes on screen is the window's draft) + #[cfg(not(target_arch = "wasm32"))] + if interacting && whole_draft { + let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); + // (a frame time that is not a number waits for nothing) + let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); + app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); + } + } +} + +/// Hovering a preset or profile: the photo with that look, shown instead of the loupe render once +/// it is ready (nothing is committed). The key of its render and the label to show. +fn request_hover(app: &mut LightcraftApp, a: &LoupeAsk, [sw, sh]: [usize; 2]) -> Option<(u64, String)> { + match app.hover_preview.clone() { + Some(h) if !a.interacting => app.session.preview_job(a.id, sw.max(8), sh.max(8), a.with_crop, &h.settings).map(|job| { + // (the same display fit as the After view, drawn texel for texel like it) + let job = job.with_display_fit(a.fit_px[0], a.fit_px[1]); + let key = job.key; + app.renderer.request(Slot::Hover, job, 105); + (key, h.label) + }), + _ => None, + } +} + +/// The Before render, at the stand-in size. +fn request_before(app: &mut LightcraftApp, a: &LoupeAsk, [sw, sh]: [usize; 2]) { + if let Some(job) = app.session.render_job(a.id, sw.max(8), sh.max(8), true, a.with_crop) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if a.interacting { job } else { job.with_display_fit(a.fit_px[0], a.fit_px[1]) }; + app.renderer.request(Slot::Before, job, 90); + } +} + +/// What a view slot shows: its own render of this photo, else the stand-ins (no blank frame +/// between photos, and the full render replaces them in place). Returns what it drew. +fn draw_slot(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, ppp: f32, slot: Slot, r: Rect, clip: Rect) -> &'static str { + let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); + let (tex, what) = if let Some(t) = mine(slot) { + (t, "render") + } else if let Some(t) = mine(Slot::Preview) { + (t, t.quick.map(quick_name).unwrap_or("preview")) + } else if let Some(t) = app.renderer.thumb(id) { + (t, "thumb") + } else { + return "none"; + }; + let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); + p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + what +} + +/// The hovered look's render over the photo, with its label. +fn draw_hover(p: &egui::Painter, t: &Tokens, tex: &crate::render::Tex, label: &str, [img_rect, canvas]: [Rect; 2], badge_top_inset: f32) { + let hover_rect = fit_texture_rect(img_rect, tex.size); + p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let g = p.layout_no_wrap(label.to_string(), t.font(12.0), Color32::WHITE); + let bg = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); + paint_badge(p, bg, g); +} + +/// A label on a dark pill. +fn paint_badge(p: &egui::Painter, bg: Rect, g: std::sync::Arc) { + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); +} + +/// No photo is active: the empty-library message if the library is empty, else "No photo selected". +fn show_no_photo(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { + if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, canvas); + } else { + super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + } +} + +/// The margin round the photo: none full screen, wide in the crop tool. +fn loupe_margin(fullscreen: bool, crop_tool: bool) -> f32 { + if fullscreen { + 0.0 + } else if crop_tool { + 48.0 + } else { + 24.0 + } +} + +/// The labels under each photo, or under its pane when the photo reaches past it. +fn split_labels(p: &egui::Painter, t: &Tokens, [before, after]: [(Rect, Rect); 2]) { + let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); + p.text(label_at(before.0, before.1), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); + p.text(label_at(after.0, after.1), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); +} + +/// What is drawn over the photo. +struct OverPhoto<'a> { + photo: &'a lightcraft_catalog::Photo, + d: &'a DevelopSettings, + map: &'a CanvasMap, + canvas: Rect, + img_rect: Rect, + fullscreen: bool, + crop_tool: bool, + /// the clipping overlay may show (not in the Before view) + clipping: bool, +} + +/// The overlays over the photo: clipping, layout image, info, filter pill, faces, focus point and +/// the transform grid. +fn photo_overlays(app: &mut LightcraftApp, ui: &mut egui::Ui, p: &egui::Painter, o: &OverPhoto) { + let (canvas, img_rect) = (o.canvas, o.img_rect); + if app.ui.show_clipping && o.clipping && !o.fullscreen { + clipping_overlay(app, p, img_rect); + } + // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) + super::layout_overlay::draw(app, p, img_rect); + register(ui, "canvas:image", img_rect); + info_overlay(app, p, canvas, o.photo); + if !o.fullscreen { + filter_pill(app, ui, canvas); + } + if app.ui.face_boxes { + face_boxes(app, ui, p, o.map, o.photo, o.d); + } + if app.ui.settings.focus_point && !o.crop_tool { + draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, o.photo, "canvas:focusPoint"); + } + transform_grid(app, p, img_rect); +} + +fn request_stand_in(app: &mut LightcraftApp, id: PhotoId, edge: usize, with_crop: bool) { + if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) + && let Some(q) = app.session.quick_view_job(id, edge.max(8), with_crop) + { + app.renderer.request_quick(Slot::Preview, q, 110); } - crate::widgets::context_menu(&resp, |ui| { - crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { - for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { - if ui.button(crate::i18n::tr(label)).clicked() { - let _ = app.run(cmd, json!({})); - } - } - }); - ui.separator(); - super::grid::context_menu(app, ui, id); - }); } /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. @@ -761,16 +985,19 @@ fn general_cursor(app: &LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img ui.ctx().set_cursor_icon(cursor); } -fn general_interaction( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - resp: &egui::Response, - map: &CanvasMap, +/// Where the photo is on the canvas. +struct PhotoPlacement { + /// the photo's rectangle img: Rect, + /// the canvas it is shown on canvas: Rect, + /// the photo's size in pixels native: [usize; 2], aspect: f32, -) { +} + +fn general_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, placement: &PhotoPlacement) { + let PhotoPlacement { img, canvas, native, aspect } = *placement; if app.ui.tool == "wbPicker" { if resp.clicked() && let Some(q) = resp.interact_pointer_pos() diff --git a/crates/ui-egui/src/panels/detail/crop.rs b/crates/ui-egui/src/panels/detail/crop.rs index 295b8f819..f88b23fdd 100644 --- a/crates/ui-egui/src/panels/detail/crop.rs +++ b/crates/ui-egui/src/panels/detail/crop.rs @@ -1,5 +1,7 @@ use super::geometry::CanvasMap; -use super::*; +use super::{ + Affine, Color32, DevelopSettings, Frame, Gesture, LightcraftApp, PhotoId, Point, Pos2, Rect, Stroke, Tokens, json, pos2, register, vec2, +}; // ------------------------------------------------------------------------ crop diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index c39fc4442..4a0de28cf 100644 --- a/crates/ui-egui/src/panels/detail/filmstrip.rs +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, LightcraftApp, PhotoId, Pos2, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, json, pos2, register, vec2}; /// Size of the Navigator mini map (points). const NAV_W: f32 = 180.0; diff --git a/crates/ui-egui/src/panels/detail/geometry.rs b/crates/ui-egui/src/panels/detail/geometry.rs index 6424dabd8..d679010f1 100644 --- a/crates/ui-egui/src/panels/detail/geometry.rs +++ b/crates/ui-egui/src/panels/detail/geometry.rs @@ -1,6 +1,6 @@ #[cfg(test)] use super::info::label_badge_rect; -use super::*; +use super::{Affine, Frame, LightcraftApp, PhotoId, Point, Pos2, Rect, Zoom, json, pos2, vec2}; /// Screen ↔ normalized-image mapping for the displayed frame. #[derive(Clone, Copy)] @@ -296,7 +296,7 @@ mod preview_geometry_tests { fn before_label_badge_keeps_clear_of_the_filter_pill_and_the_right_edge() { let text = vec2(40.0, 14.0); let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)); - let inset = super::FILTER_PILL_INSET + super::FILTER_PILL_HEIGHT; + let inset = crate::panels::detail::FILTER_PILL_INSET + crate::panels::detail::FILTER_PILL_HEIGHT; let past = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, inset); assert_eq!(past.min, pos2(108.0, 50.0 + inset + 8.0)); let under = label_badge_rect(Rect::from_min_size(pos2(200.0, 60.0), vec2(400.0, 300.0)), pane, text, inset); diff --git a/crates/ui-egui/src/panels/detail/info.rs b/crates/ui-egui/src/panels/detail/info.rs index 520ad6213..da5ae1bed 100644 --- a/crates/ui-egui/src/panels/detail/info.rs +++ b/crates/ui-egui/src/panels/detail/info.rs @@ -1,5 +1,5 @@ use super::geometry::focus_box; -use super::*; +use super::{Color32, LightcraftApp, PhotoId, Pos2, Rect, Sense, Stroke, Tokens, json, pos2, register, register_geometry, vec2}; /// 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. diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 073de8aa2..365ae2311 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -1,5 +1,8 @@ use super::geometry::CanvasMap; -use super::*; +use super::{ + Color32, DevelopSettings, Gesture, LightcraftApp, MaskShape, Point, Pos2, Rect, Sense, Stroke, StrokeKind, Tokens, ToolOverlay, json, pos2, + register, vec2, +}; /// What the user did to a face box this frame. pub(super) enum RegionEdit { @@ -30,17 +33,25 @@ const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ /// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: /// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; /// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +/// What the face/pet/focus regions of a photo are drawn from. +pub(super) struct RegionContext<'a> { + pub photo: &'a lightcraft_catalog::Photo, + /// the user's Rotate / Flip on top of the upright photo + pub orient: lightcraft_geom::Orientation, + pub hints: Option<&'a crate::panels::faces::Hints>, + /// the names to offer when a region is named + pub people: &'a [String], + pub setup: crate::panels::faces::Setup, +} + pub(super) fn region_overlay( ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, - photo: &lightcraft_catalog::Photo, - orient: lightcraft_geom::Orientation, - hints: Option<&crate::panels::faces::Hints>, - people: &[String], + regions: &RegionContext, editing: &mut Option, - setup: crate::panels::faces::Setup, ) -> Option { + let RegionContext { photo, orient, hints, people, setup } = *regions; let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); let pointer = ui.input(|i| i.pointer.hover_pos()); @@ -280,7 +291,6 @@ pub(super) fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &eg /// component (click selects its mask, drag moves the component), and brush painting. The mask /// itself shows as a rendered overlay ([`view_overlay`]). pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let long = (map.rect.width().max(map.rect.height())) as f64; let active = app.session.active_mask; let clip = app.canvas_rect.unwrap_or(map.rect); let p = &ui.painter_at(clip); @@ -299,60 +309,9 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg // mask so nothing else becomes draggable. The component check keeps this in step with the // painted region (`view_overlay` at detail.rs:1354 only covers masks with components). let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty()); - let stroke_a: u8 = if sel { 230 } else { 170 }; for (ci, c) in m.components.iter().enumerate() { - match &c.shape { - MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => { - let pts: Vec = (0..64) - .map(|i| { - let a = i as f64 / 64.0 * std::f64::consts::TAU; - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a.cos(), ry * a.sin()); - let l = frame_long_norm(map); - map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) - }) - .collect(); - p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); - - if sel { - let l = frame_long_norm(map); - for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { - let a_local = a_deg.to_radians(); - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); - let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); - p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, q)); - } - - let (s, co) = angle.to_radians().sin_cos(); - let rot_r = rx + 0.08; - let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); - let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); - p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); - p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); - grips.push((m.id, ci, 5, rot_q)); - } - } - MaskShape::Linear { start, end } if show_outline => { - let (a, b) = (map.screen(*start), map.screen(*end)); - let dir = (b - a).normalized(); - let perp = vec2(-dir.y, dir.x) * 3000.0; - let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; - for (q, alpha) in [(a, a1), (b, a2)] { - p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); - } - if sel { - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, *q)); - } - } - } - _ => {} + if show_outline { + component_outline(ui, p, map, (m.id, ci), &c.shape, sel, &mut grips); } if app.ui.mask_pins && mode.shows(sel, live) @@ -366,8 +325,98 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg } } mask_keys(app, ui, map, d, &grips); - // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude - // (clicks still on their way to the model included) + object_marks(app, p, map, d, active); + // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction + // never runs while it is open), so the eyedropper is handled here too. + if app.ui.tool == "colorRange" { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + color_range_pick(app, ui, resp, map); + return; + } + if app.ui.tool == "object" { + object_tool(app, ui, resp, map); + return; + } + if app.ui.tool == "brush" { + brush_tool(app, ui, resp, map, p, clip); + return; + } + grip_gestures(app, ui, resp, map, d, &grips); +} + +/// The outline of one mask component, and (on the selected mask) its grips, registered and +/// collected into `grips`. +fn component_outline( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + at: (u32, usize), + shape: &MaskShape, + sel: bool, + grips: &mut Vec<(u32, usize, u8, Pos2)>, +) { + let (mask, ci) = at; + let stroke_a: u8 = if sel { 230 } else { 170 }; + match shape { + MaskShape::Radial { center, rx, ry, angle, .. } => { + let pts: Vec = (0..64) + .map(|i| { + let a = i as f64 / 64.0 * std::f64::consts::TAU; + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a.cos(), ry * a.sin()); + let l = frame_long_norm(map); + map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) + }) + .collect(); + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); + + if sel { + let l = frame_long_norm(map); + for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { + let a_local = a_deg.to_radians(); + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); + let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{}", mask, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((mask, ci, i as u8 + 1, q)); + } + + let (s, co) = angle.to_radians().sin_cos(); + let rot_r = rx + 0.08; + let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); + let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); + p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:5", mask), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); + grips.push((mask, ci, 5, rot_q)); + } + } + MaskShape::Linear { start, end } => { + let (a, b) = (map.screen(*start), map.screen(*end)); + let dir = (b - a).normalized(); + let perp = vec2(-dir.y, dir.x) * 3000.0; + let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; + for (q, alpha) in [(a, a1), (b, a2)] { + p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); + } + if sel { + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{i}", mask), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((mask, ci, i as u8 + 1, *q)); + } + } + } + _ => {} + } +} + +/// Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude +/// (clicks still on their way to the model included). +fn object_marks(app: &LightcraftApp, p: &egui::Painter, map: &CanvasMap, d: &DevelopSettings, active: Option) { let pending = app.session.segmenter.pending_clicks().cloned(); for m in d.masks.iter().filter(|m| Some(m.id) == active) { for (k, c) in m.components.iter().enumerate() { @@ -386,100 +435,104 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg } } } - // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction - // never runs while it is open), so the eyedropper is handled here too. - if app.ui.tool == "colorRange" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - color_range_pick(app, ui, resp, map); - return; +} + +/// Object tool: a click includes what's under it, ⌥-click leaves it out. +fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap) { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } - // Object tool: a click includes what's under it, ⌥-click leaves it out - if app.ui.tool == "object" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - { - let n = map.norm(q); - let exclude = ui.input(|i| i.modifiers.alt); - match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { - Ok(_) => { - if let Some(mid) = app.session.active_mask { - app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); - } - } - Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), - } - } - return; - } - // brush tool - if app.ui.tool == "brush" { - let r = (app.ui.brush_size as f64 * long) as f32; - if let Some(h) = resp.hover_pos() { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); - p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); - } - if let Some(q) = resp.interact_pointer_pos() - && (resp.drag_started() || resp.dragged() || resp.clicked()) - { - let n = map.norm(q); - let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; - match &mut app.gesture { - // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) - Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} - Some(Gesture::Brush { points, pressure }) => { - record_pressure(pressure, points.len(), force); - points.push(n); - } - _ => { - let mut pressure = Vec::new(); - record_pressure(&mut pressure, 0, force); - app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let exclude = ui.input(|i| i.modifiers.alt); + match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { + Ok(_) => { + if let Some(mid) = app.session.active_mask { + app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); } } + Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), } - // the stroke being painted (the render shows it once it's committed), with the committed - // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points, pressure }) = &app.gesture { - let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); - let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; - let params = crate::brush_live::LiveParams { - clip, - scale: ui.ctx().pixels_per_point(), - r, - hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), - flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), - density: 1.0, - color: app.ui.mask_overlay_color, - opacity: opacity.clamp(0.0, 1.0), - }; - crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); - } else { - app.brush_live = None; - } - if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() - { - app.brush_live = None; - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing - let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); - let erase = app.ui.brush_erase != alt; - // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom - let connect = shift && resp.clicked() && pts.len() == 1; - let _ = app.run( - "mask.brushStroke", - json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), - ); - } - return; } +} + +/// Brush tool: the cursor, the stroke being painted and its commit. +fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect) { + let long = (map.rect.width().max(map.rect.height())) as f64; + let r = (app.ui.brush_size as f64 * long) as f32; + if let Some(h) = resp.hover_pos() { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); + } + if let Some(q) = resp.interact_pointer_pos() + && (resp.drag_started() || resp.dragged() || resp.clicked()) + { + let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; + match &mut app.gesture { + // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } + } + } + // the stroke being painted (the render shows it once it's committed), with the committed + // stroke's dab spacing, feather and flow (issue #517) + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { + let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); + let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; + let params = crate::brush_live::LiveParams { + clip, + scale: ui.ctx().pixels_per_point(), + r, + hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), + flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), + density: 1.0, + color: app.ui.mask_overlay_color, + opacity: opacity.clamp(0.0, 1.0), + }; + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); + } else { + app.brush_live = None; + } + if (resp.drag_stopped() || resp.clicked()) + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() + { + app.brush_live = None; + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing + let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); + let erase = app.ui.brush_erase != alt; + // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom + let connect = shift && resp.clicked() && pts.len() == 1; + let _ = app.run( + "mask.brushStroke", + json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), + ); + } +} + +/// Grabbing mask grips and component bodies: select, drag, release. +fn grip_gestures( + app: &mut LightcraftApp, + ui: &egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + d: &DevelopSettings, + grips: &[(u32, usize, u8, Pos2)], +) { + let active = app.session.active_mask; // the grip under the pointer: the selected mask's first, then the nearest let hit = |q: Pos2| { grips diff --git a/crates/ui-egui/src/panels/detail/retouch.rs b/crates/ui-egui/src/panels/detail/retouch.rs index 64f4c2281..d74ead9ba 100644 --- a/crates/ui-egui/src/panels/detail/retouch.rs +++ b/crates/ui-egui/src/panels/detail/retouch.rs @@ -1,6 +1,6 @@ use super::geometry::CanvasMap; use super::masks::{pin, pins_live}; -use super::*; +use super::{Color32, DevelopSettings, Gesture, LightcraftApp, Point, Pos2, Rect, Stroke, ToolOverlay, json, register, vec2}; // ------------------------------------------------------------------------ remove diff --git a/crates/ui-egui/src/panels/detail/window.rs b/crates/ui-egui/src/panels/detail/window.rs index 0b441913b..f4ec89c5b 100644 --- a/crates/ui-egui/src/panels/detail/window.rs +++ b/crates/ui-egui/src/panels/detail/window.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, LightcraftApp, PhotoId, Rect, Slot, pos2, vec2}; /// 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 diff --git a/crates/ui-egui/src/panels/edit/curve.rs b/crates/ui-egui/src/panels/edit/curve.rs index 856211e66..0eec3a5e4 100644 --- a/crates/ui-egui/src/panels/edit/curve.rs +++ b/crates/ui-egui/src/panels/edit/curve.rs @@ -1,6 +1,9 @@ use super::histogram::preview_histogram; use super::sliders::control; -use super::*; +use super::{ + Align2, Color32, DevelopSettings, Icon, LightcraftApp, Mesh, PARAM_AMOUNTS, ParametricCurve, PhotoId, Point, Pos2, Rect, RichText, Sense, Stroke, + Tokens, Vertex, controls, hex, json, paint, pos2, register, text_button, vec2, +}; // ------------------------------------------------------------------------------ tone curve diff --git a/crates/ui-egui/src/panels/edit/grading.rs b/crates/ui-egui/src/panels/edit/grading.rs index a0b430acd..61f154300 100644 --- a/crates/ui-egui/src/panels/edit/grading.rs +++ b/crates/ui-egui/src/panels/edit/grading.rs @@ -1,5 +1,8 @@ use super::sliders::control; -use super::*; +use super::{ + Align2, Color32, DevelopSettings, Icon, LightcraftApp, Mesh, PhotoId, Pos2, Rect, Sense, Stroke, Tokens, Vertex, json, paint, pos2, register, + vec2, +}; // ------------------------------------------------------------------------------ colour grading diff --git a/crates/ui-egui/src/panels/edit/histogram.rs b/crates/ui-egui/src/panels/edit/histogram.rs index 44d791c4a..085fbda5d 100644 --- a/crates/ui-egui/src/panels/edit/histogram.rs +++ b/crates/ui-egui/src/panels/edit/histogram.rs @@ -1,6 +1,9 @@ #[cfg(test)] use super::sliders::reset_tip; -use super::*; +use super::{ + Align2, Color32, Histogram, Icon, LightcraftApp, Mesh, PhotoId, Pos2, Rect, RichText, RightPanel, Sense, Slot, Stroke, Tokens, Vertex, divider, + hex, json, paint, pos2, register, vec2, +}; // ------------------------------------------------------------------------------ histogram diff --git a/crates/ui-egui/src/panels/edit/mixer.rs b/crates/ui-egui/src/panels/edit/mixer.rs index b869b32dd..0bddba375 100644 --- a/crates/ui-egui/src/panels/edit/mixer.rs +++ b/crates/ui-egui/src/panels/edit/mixer.rs @@ -1,6 +1,6 @@ use super::curve::set_tat; use super::sliders::{control, sub_title}; -use super::*; +use super::{BAND_COLORS, Color32, DevelopSettings, Icon, LightcraftApp, Sense, Stroke, Tokens, hex, json, paint, register, text_button, vec2}; // ------------------------------------------------------------------------------ colour mixer diff --git a/crates/ui-egui/src/panels/edit/sliders.rs b/crates/ui-egui/src/panels/edit/sliders.rs index aad9cdf6a..00adb9928 100644 --- a/crates/ui-egui/src/panels/edit/sliders.rs +++ b/crates/ui-egui/src/panels/edit/sliders.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, ControlSpec, DevelopSettings, Icon, LightcraftApp, PhotoId, Section, Sense, SliderOut, Tokens, Track, Value, controls, divider, json, + pos2, register, section_header, slider, vec2, +}; /// Commit a slider interaction: begin → live updates → end, so a drag is one undo step. pub fn apply_slider_out( diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 262979cc5..5064b8d40 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -770,7 +770,34 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square register(ui, format!("thumb:{}", id.0), r); let state = app.session.selection.state_of(id); let selected = state != lightcraft_engine::SelectionState::NotSelected; - // screen readers: the file, then rating / flag / label + let spoken = cell_description(app, &photo); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &spoken)); + let active = state == lightcraft_engine::SelectionState::Active; + let p = ui.painter(); + let img_rect = if square { + let base = if selected { t.cell_selected } else { t.cell }; + p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected)); + Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0)) + } else { + r + }; + paint_cell_thumbnail(app, p, &t, id, img_rect, ThumbFlags { square, selected, active, onscreen, ppp }); + // labels and badges + paint_cell_caption(app, p, &t, &photo, r, square); + paint_cell_badges(app, p, &t, &photo, img_rect, BadgeFlags { square, selected, hovered: resp.hovered() }); + if let Some(name) = &photo.copy_name { + paint_copy_tag(p, &t, name, img_rect, square); + } + if let Some(why) = &photo.preview_only { + paint_preview_pill(ui, p, &t, id, why, img_rect); + } + if photo.flag == Flag::Reject { + p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); + } + cell_interaction(app, ui, &resp, id, &photo); +} + +fn cell_description(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> String { let mut spoken = photo.file_name.clone(); if photo.rating > 0 { spoken.push_str(&crate::i18n::tr_format!(", {} star{}", photo.rating, if photo.rating == 1 { "" } else { "s" })); @@ -783,17 +810,11 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square if let Some(l) = photo.label { spoken.push_str(&crate::i18n::tr_format!(", {} label", crate::i18n::color_label(&app.session.catalog, l))); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &spoken)); - let active = state == lightcraft_engine::SelectionState::Active; - let p = ui.painter(); - let img_rect = if square { - let base = if selected { t.cell_selected } else { t.cell }; - p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected)); - Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0)) - } else { - r - }; - // thumbnail + spoken +} + +fn paint_cell_thumbnail(app: &mut LightcraftApp, p: &egui::Painter, t: &Tokens, id: PhotoId, img_rect: Rect, flags: ThumbFlags) { + let ThumbFlags { square, selected, active, onscreen, ppp } = flags; let size = thumb_px(img_rect.width().max(img_rect.height()), ppp); request_thumb(app, id, size, if onscreen { 10 } else { 5 }); if let Some(tex) = app.renderer.thumb(id) { @@ -822,7 +843,9 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text); } } - // labels and badges +} + +fn paint_cell_caption(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, photo: &lightcraft_catalog::Photo, r: Rect, square: bool) { if square && app.ui.show_filenames { let m = &photo.meta; let name = match app.ui.grid_info.as_str() { @@ -850,8 +873,12 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_filled(br, 2.0, Color32::from_gray(26)); p.galley(br.min + vec2(4.0, 1.5), g, t.text_label); } +} + +fn paint_cell_badges(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, photo: &lightcraft_catalog::Photo, img_rect: Rect, flags: BadgeFlags) { + let BadgeFlags { square, selected, hovered } = flags; let show_badges = match app.ui.settings.grid_badges { - crate::state::GridBadges::Auto => resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some(), + crate::state::GridBadges::Auto => hovered || selected || photo.rating > 0 || photo.flag != Flag::None || photo.label.is_some(), crate::state::GridBadges::Always => true, crate::state::GridBadges::Never => false, }; @@ -863,7 +890,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } if show_badges { let bar = badge_bar; - if (resp.hovered() || selected) && (square || photo.label.is_none()) { + if (hovered || selected) && (square || photo.label.is_none()) { p.rect_filled(bar, 0.0, Color32::from_black_alpha(120)); } let mut x = bar.left() + 6.0; @@ -882,40 +909,41 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.circle(pos2(right - 12.0, bar.center().y), 5.0, label_color(l), Stroke::new(1.0, Color32::from_black_alpha(120))); } } - if let Some(name) = &photo.copy_name { - // virtual copy: a folded-corner tag at the top right - let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE); - let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; - let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); - p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); - let c = pos2(br.left() + 13.0, br.center().y); - let s = 5.0; - p.add(egui::Shape::convex_polygon( - vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], - Color32::TRANSPARENT, - Stroke::new(1.2, Color32::WHITE), - )); - p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); - p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); - p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - } - if let Some(why) = &photo.preview_only { - // a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom - // left, above the badge bar (always shown: it changes what editing does) - let g = p.layout_no_wrap(crate::i18n::tr("Preview").into(), t.semibold(9.5), Color32::WHITE); - let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0); - let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0)); - p.rect_filled(br, 8.0, Color32::from_black_alpha(170)); - paint(p, Rect::from_center_size(pos2(br.left() + 9.0, br.center().y), vec2(12.0, 12.0)), Icon::Info, t.caution); - p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - register(ui, format!("badge:previewOnly:{}", id.0), br); - ui.interact(br, egui::Id::unique(("preview-only-badge", id.0)), Sense::hover()) - .on_hover_text(crate::i18n::tr_format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); - } - if photo.flag == Flag::Reject { - p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); - } - // interaction +} + +fn paint_copy_tag(p: &egui::Painter, t: &Tokens, name: &str, img_rect: Rect, square: bool) { + // virtual copy: a folded-corner tag at the top right + let g = p.layout_no_wrap(name.to_string(), t.semibold(10.5), Color32::WHITE); + let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; + let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); + p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); + let c = pos2(br.left() + 13.0, br.center().y); + let s = 5.0; + p.add(egui::Shape::convex_polygon( + vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], + Color32::TRANSPARENT, + Stroke::new(1.2, Color32::WHITE), + )); + p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); +} + +fn paint_preview_pill(ui: &egui::Ui, p: &egui::Painter, t: &Tokens, id: PhotoId, why: &str, img_rect: Rect) { + // a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom + // left, above the badge bar (always shown: it changes what editing does) + let g = p.layout_no_wrap(crate::i18n::tr("Preview").into(), t.semibold(9.5), Color32::WHITE); + let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0); + let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0)); + p.rect_filled(br, 8.0, Color32::from_black_alpha(170)); + paint(p, Rect::from_center_size(pos2(br.left() + 9.0, br.center().y), vec2(12.0, 12.0)), Icon::Info, t.caution); + p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + register(ui, format!("badge:previewOnly:{}", id.0), br); + ui.interact(br, egui::Id::unique(("preview-only-badge", id.0)), Sense::hover()) + .on_hover_text(crate::i18n::tr_format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); +} + +fn cell_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, id: PhotoId, photo: &lightcraft_catalog::Photo) { if let Some(k) = app.ui.keyword_painter.clone() { // painting: a click toggles the keyword on this photo if resp.hovered() { @@ -950,7 +978,21 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } app.ui.dragging_photos = Some(app.session.selection.ids.iter().map(|p| p.0).collect()); } - crate::widgets::context_menu(&resp, |ui| context_menu(app, ui, id)); + crate::widgets::context_menu(resp, |ui| context_menu(app, ui, id)); +} + +struct ThumbFlags { + square: bool, + selected: bool, + active: bool, + onscreen: bool, + ppp: f32, +} + +struct BadgeFlags { + square: bool, + selected: bool, + hovered: bool, } /// The header of a Local folder view: the path as a breadcrumb (each part opens that folder), @@ -1215,6 +1257,16 @@ pub fn toggle_stack(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { } pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + context_menu_open_and_edit(app, ui, id); + context_menu_rate(app, ui); + context_menu_albums(app, ui, id); + context_menu_file_actions(app, ui); + context_menu_stack_and_edits(app, ui, id); + context_menu_locate_and_export(app, ui, id); + context_menu_delete(app, ui); +} + +fn context_menu_open_and_edit(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if !app.session.selection.contains(id) { let _ = app.run("library.select", json!({"ids": [id.0]})); } @@ -1266,6 +1318,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.toast(ui.ctx(), e); } ui.separator(); +} + +fn context_menu_rate(app: &mut LightcraftApp, ui: &mut egui::Ui) { crate::widgets::menu_button(ui, crate::i18n::tr("Set Rating"), |ui| { for r in 0..=5 { if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() { @@ -1281,6 +1336,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); +} + +fn context_menu_albums(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if local_only(app, &app.session.selected()) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); @@ -1323,6 +1381,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("album.setCover", json!({"id": aid.0, "photo": id.0})); } } +} + +fn context_menu_file_actions(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } @@ -1354,6 +1415,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Create Version")).clicked() { let _ = app.run("version.create", json!({})); } +} + +fn context_menu_stack_and_edits(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { crate::widgets::menu_button(ui, crate::i18n::tr("Stack"), |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; @@ -1405,6 +1469,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.rotateRight", json!({})); } ui.separator(); +} + +fn context_menu_locate_and_export(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // the original moved or its drive is gone: point the photo at the file again // (a cached answer, checked off the UI thread) let missing = matches!(&app.session.catalog.photo(id).map(|p| p.source.clone()), Some(lightcraft_catalog::Source::File { path }) if app.session.media.availability.is_offline(path)); @@ -1437,6 +1504,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.separator(); +} + +fn context_menu_delete(app: &mut LightcraftApp, ui: &mut egui::Ui) { if crate::menubar::selection_deleted(app) { if ui.button(crate::i18n::tr("Restore")).clicked() { let _ = app.run("photo.restore", json!({})); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 522afe084..27b86aa0d 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -30,7 +30,7 @@ pub(crate) use local::{fs_cached, fs_cached_running, same_folder}; use local::{local_now, local_rows}; #[cfg(test)] pub(crate) use rows::{INDENT_STEP, MIN_INDENT_STEP}; -use rows::{LIBFOLDER_REVEALED, SCROLL_TO_ROW, row, set_tree_open}; +use rows::{LIBFOLDER_REVEALED, Row, SCROLL_TO_ROW, set_tree_open}; pub(crate) use rows::{content_width, indent_step, step_for}; pub(crate) use sections::PlacedSection; pub use sections::section_drag_feedback; @@ -90,18 +90,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("recentlyAdded", Icon::Clock, "Recently Added", None, LibrarySource::RecentlyAdded), ("picks", Icon::FlagPick, "Picks", Some(picks), LibrarySource::Picks), ] { - if row(app, ui, id, icon, label, count, src == s, 0.0).clicked() { + if Row::new(id, icon, label).count(count).selected(src == s).show(app, ui).clicked() { let _ = app.run("library.source", json!({"kind": id})); } } // photos whose files can't be found (checked every few seconds, not every frame) let missing = missing_count(app, ui); if (missing > 0 || src == LibrarySource::Missing) - && row(app, ui, "missing", Icon::Folder, "Missing Photos", Some(missing), src == LibrarySource::Missing, 0.0).clicked() + && Row::new("missing", Icon::Folder, "Missing Photos") + .count(Some(missing)) + .selected(src == LibrarySource::Missing) + .show(app, ui) + .clicked() { let _ = app.run("library.source", json!({"kind": "missing"})); } - if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { + if Row::new("recentlyDeleted", Icon::Trash, "Recently Deleted") + .count(Some(deleted)) + .selected(src == LibrarySource::RecentlyDeleted) + .show(app, ui) + .clicked() + { let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); } // the album just made: the folders down to it open, once (also when the section is shut) diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs index e0204c826..92498eb02 100644 --- a/crates/ui-egui/src/panels/left/albums.rs +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -1,6 +1,6 @@ use super::folders::{folder_menu, is_within}; -use super::rows::{INDENT_STEP, indent_x, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::*; +use super::rows::{INDENT_STEP, Row, indent_x, set_tree_open, tree_open, tree_toggle}; +use super::{Album, AlbumId, Align2, Icon, LibrarySource, LightcraftApp, Rect, Sense, Tokens, json, pos2, register, vec2}; /// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, /// whose folders open down to it. @@ -34,8 +34,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare open = true; set_tree_open(ui, open_id, true); } - let resp = - row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); + let resp = Row::new(&format!("folder:{}", a.id.0), Icon::Folder, &a.name).indent(indent).sense(Sense::click_and_drag()).show(app, ui); // a folder is no source, so its row folds it too; the triangle is the same click, aimed let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); if resp.drag_started() { @@ -74,7 +73,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare // a smart album whose rules no longer check (an album they test was deleted) says so let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default(); let label = if problems.is_empty() { label } else { format!("⚠ {label}") }; - let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None); + let mut resp = Row::new(&format!("album:{}", a.id.0), icon, &label) + .count(Some(n)) + .selected(sel) + .indent(indent) + .sense(Sense::click_and_drag()) + .show(app, ui); if !problems.is_empty() { crate::widgets::register(ui, format!("albumProblem:{}", a.id.0), resp.rect); } @@ -393,23 +397,35 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); if released { - match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { - Ok(r) => { - let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); - let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); - if skipped > 0 { - msg.push_str(" · "); - msg.push_str(&crate::i18n::tr_format!( - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", - if skipped == 1 { "" } else { "s" }, - n = skipped - )); - } - app.toast(ui.ctx(), msg); - } - Err(e) => app.toast(ui.ctx(), e), - } + drop_photos_on_album(app, ui, a, ids); app.ui.dragging_photos = None; } } + +/// Adds the dragged photos to the album and toasts how many went in (and how many Local ones were skipped). +fn drop_photos_on_album(app: &mut LightcraftApp, ui: &egui::Ui, a: &Album, ids: Vec) { + match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { + Ok(r) => app.toast(ui.ctx(), added_message(a, &r)), + Err(e) => app.toast(ui.ctx(), e), + } +} + +/// "Added N photos to “album”", plus a note when Local photos were skipped. +fn added_message(a: &Album, r: &serde_json::Value) -> String { + let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); + let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); + if skipped > 0 { + msg.push_str(" · "); + msg.push_str(&skipped_note(skipped)); + } + msg +} + +fn skipped_note(skipped: u64) -> String { + crate::i18n::tr_format!( + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", + if skipped == 1 { "" } else { "s" }, + n = skipped + ) +} diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs index 49d8cf898..2971cb2f4 100644 --- a/crates/ui-egui/src/panels/left/dates.rs +++ b/crates/ui-egui/src/panels/left/dates.rs @@ -1,5 +1,5 @@ -use super::rows::{INDENT_STEP, row, tree_open, tree_toggle}; -use super::*; +use super::rows::{INDENT_STEP, Row, tree_open, tree_toggle}; +use super::{Icon, LibrarySource, LightcraftApp, json}; /// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. pub(super) fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { @@ -24,7 +24,7 @@ fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, let open_id = egui::Id::unique(("date-open", key.to_string())); let mut open = tree_open(ui, open_id, false); let sel = app.session.filter.date.as_deref() == Some(key); - let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); + let resp = Row::new(&format!("date:{key}"), Icon::Clock, label).count(Some(count)).selected(sel).indent(indent).show(app, ui); if key.len() < 10 { tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); } diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index b9aa33037..20c1bbce1 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -1,7 +1,7 @@ use super::albums::pointer_over_row; use super::local::{same_folder, subfolders}; -use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::*; +use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, Row, out_of_sight, set_tree_open, tree_open, tree_toggle}; +use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Tokens, json, register, vec2}; /// A folder on disk with a disclosure triangle: click browses it, the triangle lists its /// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the @@ -36,70 +36,72 @@ pub(super) fn folder_tree( ui.data_mut(|d| d.insert_temp(revealed_id, target)); } } - // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the - // icon lines up with the rows above - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); - let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); - if !tr.clicked() - && resp.clicked() - && let Err(e) = app.run("library.browse", json!({"path": path})) - { - app.toast(ui.ctx(), e); - } - crate::widgets::context_menu(&resp, |ui| { - // the library actions of the Folders tree, when this folder (or one inside it) holds library photos - let library = app.caches.folder_tree(&app.session.catalog); - let held = library_node_for(&library, path); - if (transient || indent > 0.0) - && ui - .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) - .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) - .clicked() + if !out_of_sight(app, ui, &format!("local:{path}"), name, None, indent, false) { + // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the + // icon lines up with the rows above + let resp = Row::new(&format!("local:{path}"), Icon::Folder, name).selected(sel).indent(indent).show(app, ui).on_hover_text(path); + let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); + if !tr.clicked() + && resp.clicked() + && let Err(e) = app.run("library.browse", json!({"path": path})) { - let _ = app.run("local.addRoot", json!({"path": path})); - ui.close(); + app.toast(ui.ctx(), e); } - if indent == 0.0 - && ui - .button(crate::i18n::tr("Remove from Local")) - .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) - .clicked() - { - let _ = app.run("local.hide", json!({"path": path})); - ui.close(); - } - if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("Rename “{name}”", name = name), - hint: "Folder name (renamed on disk; its photos follow)".into(), - value: name.to_string(), - command: "folder.rename".into(), - params: json!({"path": path}), - key: "name".into(), - }); - } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), + crate::widgets::context_menu(&resp, |ui| { + // the library actions of the Folders tree, when this folder (or one inside it) holds library photos + let library = app.caches.folder_tree(&app.session.catalog); + let held = library_node_for(&library, path); + if (transient || indent > 0.0) + && ui + .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) + .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) + .clicked() + { + let _ = app.run("local.addRoot", json!({"path": path})); + ui.close(); + } + if indent == 0.0 + && ui + .button(crate::i18n::tr("Remove from Local")) + .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) + .clicked() + { + let _ = app.run("local.hide", json!({"path": path})); + ui.close(); + } + if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("Rename “{name}”", name = name), + hint: "Folder name (renamed on disk; its photos follow)".into(), + value: name.to_string(), + command: "folder.rename".into(), + params: json!({"path": path}), + key: "name".into(), + }); + } + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { + let into = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(into) = into { + match app.run("folder.move", json!({"path": path, "into": into})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), + Err(e) => app.toast(ui.ctx(), e), + } } } - } - if app.services.reveal.is_some() - && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() - && let Some(f) = app.services.reveal.as_mut() - { - let _ = f(path); - } - if let Some(held) = &held { - synchronize_item(app, ui, &held.0, &held.1); - } - }); + if app.services.reveal.is_some() + && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() + && let Some(f) = app.services.reveal.as_mut() + { + let _ = f(path); + } + if let Some(held) = &held { + synchronize_item(app, ui, &held.0, &held.1); + } + }); + } if open && indent < INDENT_STEP * 8.0 { - for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false); + for (n, p) in subfolders(ui, path).iter() { + folder_tree(app, ui, place, n, p, indent + INDENT_STEP, current, reveal, false); } } } @@ -339,7 +341,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s None => name.to_string(), }; let mark = n.label.map(crate::theme::label_color); - let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + let resp = Row::new(id, Icon::Folder, name).spoken(&spoken).count(Some(n.count)).selected(sel).indent(indent).mark(mark).show(app, ui); folder_drop_target(app, ui, &resp, n, open_id, open); let mut toggled = false; if !n.children.is_empty() { diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs index 81997f2e2..d8e1720a9 100644 --- a/crates/ui-egui/src/panels/left/keywords.rs +++ b/crates/ui-egui/src/panels/left/keywords.rs @@ -1,6 +1,6 @@ use super::dates::browse_all_photos; -use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle}; -use super::*; +use super::rows::{INDENT_STEP, Row, out_of_sight, tree_open, tree_toggle}; +use super::{Icon, KeywordNode, LightcraftApp, json}; /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// filters the grid by the keyword (children included), the triangle opens a level, and the @@ -23,7 +23,7 @@ pub(super) fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[ /// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option, open: &mut bool) { let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); - let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); + let resp = Row::new(id, Icon::Tag, &n.name).count(Some(n.count)).selected(sel).indent(indent).show(app, ui); if let Some(k) = open_id { tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); } diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs index 0fedcb2b5..1308059c8 100644 --- a/crates/ui-egui/src/panels/left/local.rs +++ b/crates/ui-egui/src/panels/left/local.rs @@ -1,6 +1,6 @@ use super::folders::folder_tree; -use super::rows::row; -use super::*; +use super::rows::Row; +use super::{Icon, LibrarySource, LightcraftApp, SidebarSection, json, register}; /// Local's folders as they stand this frame, and the folder being browsed among them. pub(super) struct LocalNow { @@ -19,8 +19,8 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option = Vec::new(); - if !home.is_empty() { + let mut builtin = app.services.local_places.clone().unwrap_or_default(); + if app.services.local_places.is_none() && !home.is_empty() { for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] { // joined with the platform's separator, like the paths browsing gives back let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }; @@ -35,8 +35,19 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option