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..8477823bf 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f 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..31dfd70f0 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. diff --git a/CLAUDE.md b/CLAUDE.md index 416ffc0a8..7005cac3b 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…). diff --git a/Cargo.lock b/Cargo.lock index cff5ae6a9..461191754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3535,7 +3535,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "craft-update", @@ -3562,7 +3562,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "crc32fast", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3598,7 +3598,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.14.0" +version = "0.15.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3638,7 +3638,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.14.0" +version = "0.15.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3655,7 +3655,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "lightcraft-raster", @@ -3671,7 +3671,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3683,7 +3683,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "ab_glyph", "chrono", @@ -3727,7 +3727,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3742,7 +3742,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "rustls", @@ -3753,7 +3753,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.14.0" +version = "0.15.0" dependencies = [ "miniz_oxide 0.9.1", "serde", @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -3769,7 +3769,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.14.0" +version = "0.15.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.14.0" +version = "0.15.0" dependencies = [ "heic-rs", "rayon", @@ -3797,14 +3797,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", @@ -3813,7 +3813,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3824,7 +3824,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3840,7 +3840,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "lightcraft-geom", @@ -3853,7 +3853,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "image", @@ -3872,7 +3872,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.14.0" +version = "0.15.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3884,7 +3884,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.14.0" +version = "0.15.0" dependencies = [ "libc", "lightcraft-color", @@ -3896,7 +3896,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.14.0" +version = "0.15.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3920,7 +3920,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.14.0" +version = "0.15.0" dependencies = [ "image", "lightcraft-color", @@ -3931,7 +3931,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.14.0" +version = "0.15.0" dependencies = [ "candle-core", "candle-nn", @@ -3945,7 +3945,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.14.0" +version = "0.15.0" dependencies = [ "lightcraft-raster", "serde", @@ -3954,15 +3954,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", @@ -3971,7 +3971,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-ui", "craft-update", @@ -4002,7 +4002,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "crc32fast", "eframe", @@ -7819,7 +7819,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 42d4e5209..2056f3e49 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" @@ -122,9 +122,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..6c556e46f 100644 --- a/README.md +++ b/README.md @@ -563,9 +563,12 @@ 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. ### 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/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/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/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/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/tests.rs b/crates/catalog/src/tests.rs index 579175086..bf67b2495 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -904,17 +904,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 +932,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/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/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/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/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 3cb3be3cb..862c72cd0 100644 --- a/crates/engine/src/export/encoding.rs +++ b/crates/engine/src/export/encoding.rs @@ -1,6 +1,9 @@ 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). 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 80eb13430..139939a1d 100644 --- a/crates/engine/src/export/tests.rs +++ b/crates/engine/src/export/tests.rs @@ -1067,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})); @@ -1092,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..8f6a9c28c 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,4 +1,4 @@ -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; 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/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/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/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/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/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/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/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/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 a930af00c..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). 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/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/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/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/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..2b58595d2 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_some_and(|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 75933d4c7..ac11e691e 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 211eed84b..69cdf050f 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -771,7 +771,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" })); @@ -784,17 +811,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) { @@ -823,7 +844,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() { @@ -851,8 +874,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, }; @@ -864,7 +891,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; @@ -883,40 +910,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() { @@ -951,7 +979,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), @@ -1216,6 +1258,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]})); } @@ -1267,6 +1319,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() { @@ -1282,6 +1337,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()); @@ -1324,6 +1382,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!({})); } @@ -1355,6 +1416,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; @@ -1406,6 +1470,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)); @@ -1438,6 +1505,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 95915b740..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 @@ -39,7 +39,7 @@ pub(super) fn folder_tree( 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(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); + 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() @@ -341,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 e1a62d8ea..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 { @@ -61,8 +61,7 @@ pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &Local let reveal = local.owner == Some(i); folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient); } - if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() - { + if app.services.pick_folder.is_some() && Row::new("local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…")).show(app, ui).clicked() { let picked = app.services.pick_folder.as_mut().and_then(|f| f()); if let Some(path) = picked { match app.run("library.browse", json!({"path": path})) { @@ -80,7 +79,8 @@ pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &Local let hidden = now.hidden.len(); if hidden > 0 { let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); - let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + let resp = Row::new("local:restoreHidden", Icon::Folder, &label) + .show(app, ui) .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); if let [(_, only)] = now.hidden.as_slice() { if resp.clicked() { @@ -505,7 +505,7 @@ mod tests { for frame in 0..3 { let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]); view.run(raw, |ui| { - super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0); + super::Row::new("all", super::Icon::Photos, "All Photos").show(&mut app, ui); super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); }); widgets = crate::widgets::take_registry(&view.ctx); diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs index 541d98b06..ac364aa36 100644 --- a/crates/ui-egui/src/panels/left/rows.rs +++ b/crates/ui-egui/src/panels/left/rows.rs @@ -1,5 +1,5 @@ use super::albums::MARK_W; -use super::*; +use super::{Align2, Icon, LightcraftApp, Rect, Sense, Tokens, paint, pos2, register, vec2}; /// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are /// drawn at this width (or the panel's, if wider), and the sidebar scrolls sideways when it @@ -77,50 +77,66 @@ pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) } -pub(super) fn row( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, +/// One row of the sidebar, as it is drawn: build it with [`Row::new`] and the setters, then [`Row::show`] it. +pub(super) struct Row<'a> { + id: &'a str, icon: Icon, - label: &str, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_named(app, ui, id, icon, label, None, count, selected, indent) -} - -/// [`row`] whose spoken name is `spoken` when the painted `label` is a shortened form of it. -fn row_named( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None) -} - -/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour -/// label's dot (`mark`) before the count. -#[allow(clippy::too_many_arguments)] -pub(super) fn row_sensed( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, + label: &'a str, + /// the spoken name when the painted `label` is a shortened form of it + spoken: Option<&'a str>, count: Option, selected: bool, indent: f32, + /// rows that can be dragged add `drag` sense: Sense, + /// a colour label's dot, before the count mark: Option, -) -> egui::Response { +} + +impl<'a> Row<'a> { + pub(super) fn new(id: &'a str, icon: Icon, label: &'a str) -> Self { + Row { id, icon, label, spoken: None, count: None, selected: false, indent: 0.0, sense: Sense::click(), mark: None } + } + + pub(super) fn spoken(mut self, spoken: &'a str) -> Self { + self.spoken = Some(spoken); + self + } + + pub(super) fn count(mut self, count: Option) -> Self { + self.count = count; + self + } + + pub(super) fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub(super) fn indent(mut self, indent: f32) -> Self { + self.indent = indent; + self + } + + pub(super) fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } + + pub(super) fn mark(mut self, mark: Option) -> Self { + self.mark = mark; + self + } + + /// Draw the row; the response is the row's. + pub(super) fn show(self, app: &mut LightcraftApp, ui: &mut egui::Ui) -> egui::Response { + draw_row(app, ui, self) + } +} + +/// Draw a [`Row`]. +fn draw_row(app: &mut LightcraftApp, ui: &mut egui::Ui, row: Row) -> egui::Response { + let Row { id, icon, label, spoken, count, selected, indent, sense, mark } = row; let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); let indent = drawn_indent(ui, indent); diff --git a/crates/ui-egui/src/panels/left/sections.rs b/crates/ui-egui/src/panels/left/sections.rs index 42cc631b5..5597904a8 100644 --- a/crates/ui-egui/src/panels/left/sections.rs +++ b/crates/ui-egui/src/panels/left/sections.rs @@ -1,6 +1,6 @@ use super::albums::top_level_drop_target; use super::rows::{ROW_HEIGHT, note_width}; -use super::*; +use super::{Align2, Icon, LightcraftApp, Rect, Sense, SidebarSection, Tokens, icon_button, json, paint, pos2, register, vec2}; /// Space above a section's header. pub(super) const SECTION_GAP: f32 = 10.0; diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index 86f3796b9..d1c4fe1f4 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -683,23 +683,38 @@ pub fn manage_body( } } +/// The distinct values, sorted. +fn distinct<'a>(values: impl Iterator) -> Vec<&'a str> { + let mut v: Vec<&str> = values.collect(); + v.sort_unstable(); + v.dedup(); + v +} + /// The toast after an import (`preset.import` / `mask.importEffects` result): how many effects, /// in how many groups, and the settings they carry that LightCraft can't apply. pub fn import_toast(v: &Value) -> String { let rows: Vec<&Value> = v["effects"].as_array().or_else(|| v["imported"].as_array()).into_iter().flatten().collect(); - let n = rows.len(); - let mut msg = crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if n == 1 { "" } else { "s" }, e = n); - let mut groups: Vec<&str> = rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty()).collect(); - groups.sort_unstable(); - groups.dedup(); + let mut msg = imported_effects_message(rows.len()); + let groups = distinct(rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty())); if !groups.is_empty() { - msg += &crate::i18n::tr_format!(" in {g} group{}", if groups.len() == 1 { "" } else { "s" }, g = groups.len()); + msg += &in_groups_message(groups.len()); } - let mut skipped: Vec<&str> = rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str).collect(); - skipped.sort_unstable(); - skipped.dedup(); + let skipped = distinct(rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str)); if !skipped.is_empty() { - msg += &crate::i18n::tr_format!(" — kept but not applied: {}", skipped.join(", ")); + msg += ¬_applied_message(&skipped); } msg } + +fn imported_effects_message(n: usize) -> String { + crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", crate::i18n::plural_s(n), e = n) +} + +fn in_groups_message(groups: usize) -> String { + crate::i18n::tr_format!(" in {g} group{}", crate::i18n::plural_s(groups), g = groups) +} + +fn not_applied_message(settings: &[&str]) -> String { + crate::i18n::tr_format!(" — kept but not applied: {}", settings.join(", ")) +} diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 4dbbc1fd9..96e356bb4 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -40,6 +40,22 @@ pub fn gpu_notice(reason: Option<&str>, enabled: bool) -> Option { Some(text) } +/// The quit prompt's text while `count` cancellable tasks run (`names` lists them). +fn tasks_message(count: usize, names: &str) -> String { + if count == 1 { + crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names) + } else { + crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = count, names = names) + } +} + +/// The quit prompt's text when `n` changes couldn't be written to disk, for the reason `e`. +fn unsaved_message(n: usize, e: &str) -> String { + let s = crate::i18n::plural_s(n); + let lost = if n == 1 { "it is" } else { "they are" }; + crate::i18n::tr_format!("{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.", s, lost, n = n, e = e) +} + /// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that /// can be cancelled (an import, an export…) are asked about first; then changes that couldn't be /// written are retried — the log, then a snapshot of everything in memory. @@ -51,11 +67,7 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { let running = app.session.activity.running_cancellable(); if !running.is_empty() { let names = running.iter().map(|t| crate::i18n::tr(&t.label)).collect::>().join(", "); - app.quit_prompt = Some(QuitPrompt::Tasks(if running.len() == 1 { - crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names) - } else { - crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = running.len(), names = names) - })); + app.quit_prompt = Some(QuitPrompt::Tasks(tasks_message(running.len(), &names))); return false; } if app.session.unsaved().is_none() { @@ -70,13 +82,7 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { match app.session.unsaved() { None => true, Some((n, e)) => { - app.quit_prompt = Some(QuitPrompt::Unsaved(crate::i18n::tr_format!( - "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.", - if n == 1 { "" } else { "s" }, - if n == 1 { "it is" } else { "they are" }, - n = n, - e = e - ))); + app.quit_prompt = Some(QuitPrompt::Unsaved(unsaved_message(n, e))); false } } diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 98b738cf2..0b6b946aa 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -221,7 +221,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let Some(f) = page.confirmed.get(row * cols + col) else { break }; shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), PAD + row as f32 * row_h); - if let Hit::Open = tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, false, &shown_name, 0) { + if let Hit::Open = + tile(app, ui, &TileSpec { r: Rect::from_min_size(min, vec2(edge, edge)), f, ppp, more: false, name: &shown_name, above_count: 0 }) + { hit = Some((Hit::Open, f.clone())); } } @@ -254,7 +256,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), grid_top + row as f32 * row_h); let above_count = more.iter().filter(|candidate| candidate.score >= f.score).count(); - match tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, true, &shown_name, above_count) { + match tile(app, ui, &TileSpec { r: Rect::from_min_size(min, vec2(edge, edge)), f, ppp, more: true, name: &shown_name, above_count }) { Hit::Confirm => hit = Some((Hit::Confirm, (*f).clone())), Hit::ConfirmAbove => hit = Some((Hit::ConfirmAbove, (*f).clone())), Hit::Dismiss => hit = Some((Hit::Dismiss, (*f).clone())), @@ -287,9 +289,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { } } +/// What one face tile shows. +struct TileSpec<'a> { + /// where it is drawn + r: Rect, + f: &'a Face, + /// pixels per point, for the size of the face picture + ppp: f32, + /// a "More" tile (a face that looks like the person) rather than a confirmed one + more: bool, + /// the person's name + name: &'a str, + /// how many shown faces look at least as much like the person (for a "More" tile) + above_count: usize, +} + /// One face tile. A confirmed one opens its photo when clicked; a "More" one is confirmed when clicked, and has a × that /// hides it. -fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str, above_count: usize) -> Hit { +fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, spec: &TileSpec) -> Hit { + let TileSpec { r, f, ppp, more, name, above_count } = *spec; let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; let resp = ui.interact(r, egui::Id::unique((salt, f.photo, f.index)), Sense::click()); diff --git a/crates/ui-egui/src/panels/right/crop.rs b/crates/ui-egui/src/panels/right/crop.rs index 67efd91f6..0b71fffe8 100644 --- a/crates/ui-egui/src/panels/right/crop.rs +++ b/crates/ui-egui/src/panels/right/crop.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LightcraftApp, PhotoId, Tokens, divider, header, json, padded, register, slider, text_button}; /// The aspect presets of the Crop panel: (menu label, `crop.aspect` value). const ASPECT_PRESETS: [(&str, &str); 10] = [ diff --git a/crates/ui-egui/src/panels/right/history.rs b/crates/ui-egui/src/panels/right/history.rs index 49229783b..d434075ad 100644 --- a/crates/ui-egui/src/panels/right/history.rs +++ b/crates/ui-egui/src/panels/right/history.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Icon, LightcraftApp, PhotoId, Rect, Sense, Tokens, header, json, padded, paint, pos2, register, text_button, vec2}; pub(super) fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/right/info.rs b/crates/ui-egui/src/panels/right/info.rs index 4d7c198a0..5810a0f27 100644 --- a/crates/ui-egui/src/panels/right/info.rs +++ b/crates/ui-egui/src/panels/right/info.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, LightcraftApp, PhotoId, Sense, Tokens, divider, header, json, padded, register, text_button, vec2}; pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/right/keywords.rs b/crates/ui-egui/src/panels/right/keywords.rs index 6d7d5868b..b6598476a 100644 --- a/crates/ui-egui/src/panels/right/keywords.rs +++ b/crates/ui-egui/src/panels/right/keywords.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HOLD, LightcraftApp, PhotoId, Rect, Tokens, fold_header, json, padded, register, text_button, vec2}; pub(super) fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // (the box is the selection's: the active photo only says that there is one) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 2ba807dcc..5e3fea8a1 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -51,185 +51,202 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(ui, bar); } - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) - .clicked() - { - let _ = app.run("view.leftPanel", json!({})); - } - ui.add_space(8.0); - if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() { - let _ = app.run("view.back", json!({})); - } - icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward")); - if !app.native_menu { - // no native menu bar (web, Windows, Linux): menus in the top bar - ui.add_space(10.0); - let room = search_left - ui.cursor().left() - 16.0; - crate::menubar::show_in_window(app, ui, room); - } - }); - // search field (centred on the window) - let id = crate::text_field::id("field:search"); - if std::mem::take(&mut app.ui.focus_search) { - ui.memory_mut(|m| m.request_focus(id)); + nav_buttons(app, ui, search_left); + search_field(app, ui, &t, sr); + filter_button(app, ui, &t, sr); + let x = right_icons(app, ui, &t, full); + appearance_button(app, ui, &t, full, x); + }); +} + +/// Sidebar toggle, back/forward and, without a native menu bar, the in-window menus. +fn nav_buttons(app: &mut LightcraftApp, ui: &mut egui::Ui, search_left: f32) { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) + .clicked() + { + let _ = app.run("view.leftPanel", json!({})); + } + ui.add_space(8.0); + if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() { + let _ = app.run("view.back", json!({})); + } + icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward")); + if !app.native_menu { + // no native menu bar (web, Windows, Linux): menus in the top bar + ui.add_space(10.0); + let room = search_left - ui.cursor().left() - 16.0; + crate::menubar::show_in_window(app, ui, room); + } + }); +} + +/// The centred search field with its placeholder and focus ring. +fn search_field(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, sr: Rect) { + // search field (centred on the window) + let id = crate::text_field::id("field:search"); + if std::mem::take(&mut app.ui.focus_search) { + ui.memory_mut(|m| m.request_focus(id)); + } + let focused = ui.memory(|m| m.has_focus(id)); + ui.painter().rect( + sr, + 4.0, + if focused { t.canvas } else { t.field }, + Stroke::new(1.0, if focused { t.accent } else { t.field_border }), + StrokeKind::Inside, + ); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let empty = app.ui.search.is_empty(); + if empty && !focused { + let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim); + let w = g.size().x + 24.0; + let x0 = sr.center().x - w / 2.0; + paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim); + child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); + } + let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) + .label(crate::i18n::tr("Search Photos")) + .frame(false) + .width(sr.width() - 20.0) + .font(t.font(13.5)) + .text_color(t.text) + .show(&mut child) + .response; + if resp.changed() { + let q = app.ui.search.clone(); + let _ = app.run("library.filter", json!({"text": q})); + } +} + +/// The filter icon beside the search field, with the active-filter badge. +fn filter_button(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, sr: Rect) { + // filter icon right of the search field + let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); + let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click()); + crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); + register(ui, "icon:filter", fr); + let filtering = app.session.filter != Default::default() || app.ui.filter_bar; + paint( + ui.painter(), + fr.shrink(6.0), + Icon::Filter, + if filtering { + t.accent + } else if fresp.hovered() { + t.text + } else { + t.icon + }, + ); + // badge: how many filters are on, even with the filter bar closed + let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len(); + if active > 0 { + let c = fr.right_top() + vec2(-3.0, 8.0); + ui.painter().circle_filled(c, 7.0, t.accent); + ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas); + } + let fresp = fresp.on_hover_text(if active > 0 { + crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) + } else { + crate::i18n::tr("Filter bar").into() + }); + if fresp.clicked() { + let _ = app.run("view.filterBar", json!({})); + } +} + +/// The right-side action icons; returns the x where the next icon would go. +fn right_icons(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rect) -> f32 { + // right icons + let mut x = full.right() - 18.0; + // saving is failing: the cloud icon turns into a warning until a save succeeds + let unsaved = app.session.unsaved().map(|(n, e)| unsaved_tip(n, e)); + let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); + // Import Photos… beside Export, always one click away, with the key it is bound to now + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { + Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), + None => crate::i18n::tr("Import Photos…").to_string(), + }; + for (id, icon, tip, cmd) in [ + ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), + ("cloud", Icon::Cloud, cloud_tip, ""), + ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), + ("share", Icon::Share, "Export", "dialog.export"), + ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), + ("bell", Icon::Bell, "Activity", "panel.activity"), + ] { + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + crate::widgets::announce(&resp, crate::i18n::tr(tip)); + register(ui, format!("icon:{id}"), r); + let warn = id == "cloud" && unsaved.is_some(); + let colour = if warn { + t.caution + } else if resp.hovered() { + t.text + } else { + t.icon + }; + paint(ui.painter(), r.shrink(5.0), icon, colour); + if warn { + let c = r.right_top() + vec2(-5.0, 6.0); + ui.painter().circle_filled(c, 6.0, t.reject); + ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); + register(ui, "indicator:unsaved", r); + } + // a refusal says why (Import while a Lightroom catalog is coming in) + if resp.clicked() + && !cmd.is_empty() + && let Err(e) = app.run(cmd, json!({})) + { + app.toast_error(ui.ctx(), e); + } + x -= 40.0; + } + x +} + +/// The appearance icon at `x`, with its theme-favourites menu. +fn appearance_button(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rect, x: f32) { + // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode + let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); + let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + crate::widgets::announce(&resp, tip.clone()); + register(ui, "icon:appearance", r); + paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); + if resp.clicked() { + let _ = app.run("view.appearance", json!({})); + } + crate::widgets::context_menu(&resp, |ui| { + let settings = ui.button(crate::i18n::tr("Appearance Settings…")); + register(ui, "menu:appearanceSettings", settings.rect); + if settings.clicked() { + let _ = app.run("app.settings", json!({"tab": "interface"})); + ui.close(); + } + let favorites = app.ui.settings.favorite_themes.clone(); + for dark in [false, true] { + let group: Vec<_> = + crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); + if !group.is_empty() { + ui.separator(); } - let focused = ui.memory(|m| m.has_focus(id)); - ui.painter().rect( - sr, - 4.0, - if focused { t.canvas } else { t.field }, - Stroke::new(1.0, if focused { t.accent } else { t.field_border }), - StrokeKind::Inside, - ); - let mut child = - ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - let empty = app.ui.search.is_empty(); - if empty && !focused { - let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim); - let w = g.size().x + 24.0; - let x0 = sr.center().x - w / 2.0; - paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim); - child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); - } - let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) - .label(crate::i18n::tr("Search Photos")) - .frame(false) - .width(sr.width() - 20.0) - .font(t.font(13.5)) - .text_color(t.text) - .show(&mut child) - .response; - if resp.changed() { - let q = app.ui.search.clone(); - let _ = app.run("library.filter", json!({"text": q})); - } - // filter icon right of the search field - let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); - let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click()); - crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); - register(ui, "icon:filter", fr); - let filtering = app.session.filter != Default::default() || app.ui.filter_bar; - paint( - ui.painter(), - fr.shrink(6.0), - Icon::Filter, - if filtering { - t.accent - } else if fresp.hovered() { - t.text - } else { - t.icon - }, - ); - // badge: how many filters are on, even with the filter bar closed - let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len(); - if active > 0 { - let c = fr.right_top() + vec2(-3.0, 8.0); - ui.painter().circle_filled(c, 7.0, t.accent); - ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas); - } - let fresp = fresp.on_hover_text(if active > 0 { - crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) - } else { - crate::i18n::tr("Filter bar").into() - }); - if fresp.clicked() { - let _ = app.run("view.filterBar", json!({})); - } - // right icons - let mut x = full.right() - 18.0; - // saving is failing: the cloud icon turns into a warning until a save succeeds - let unsaved = app.session.unsaved().map(|(n, e)| { - crate::i18n::tr_format!( - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.", - if n == 1 { "" } else { "s" }, - if n == 1 { "it" } else { "them" }, - e = e, - n = n - ) - }); - let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); - // Import Photos… beside Export, always one click away, with the key it is bound to now - let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; - let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { - Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), - None => crate::i18n::tr("Import Photos…").to_string(), - }; - for (id, icon, tip, cmd) in [ - ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), - ("cloud", Icon::Cloud, cloud_tip, ""), - ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), - ("share", Icon::Share, "Export", "dialog.export"), - ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), - ("bell", Icon::Bell, "Activity", "panel.activity"), - ] { - let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); - let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); - crate::widgets::announce(&resp, crate::i18n::tr(tip)); - register(ui, format!("icon:{id}"), r); - let warn = id == "cloud" && unsaved.is_some(); - let colour = if warn { - t.caution - } else if resp.hovered() { - t.text - } else { - t.icon - }; - paint(ui.painter(), r.shrink(5.0), icon, colour); - if warn { - let c = r.right_top() + vec2(-5.0, 6.0); - ui.painter().circle_filled(c, 6.0, t.reject); - ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); - register(ui, "indicator:unsaved", r); - } - // a refusal says why (Import while a Lightroom catalog is coming in) - if resp.clicked() - && !cmd.is_empty() - && let Err(e) = app.run(cmd, json!({})) - { - app.toast_error(ui.ctx(), e); - } - x -= 40.0; - } - // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode - let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); - let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); - let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); - let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); - crate::widgets::announce(&resp, tip.clone()); - register(ui, "icon:appearance", r); - paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); - if resp.clicked() { - let _ = app.run("view.appearance", json!({})); - } - crate::widgets::context_menu(&resp, |ui| { - let settings = ui.button(crate::i18n::tr("Appearance Settings…")); - register(ui, "menu:appearanceSettings", settings.rect); - if settings.clicked() { - let _ = app.run("app.settings", json!({"tab": "interface"})); + for kind in group { + let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); + register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect); + if r.clicked() { + let _ = app.run("view.theme", json!({"theme": kind.id()})); ui.close(); } - let favorites = app.ui.settings.favorite_themes.clone(); - for dark in [false, true] { - let group: Vec<_> = - crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); - if !group.is_empty() { - ui.separator(); - } - for kind in group { - let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); - register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect); - if r.clicked() { - let _ = app.run("view.theme", json!({"theme": kind.id()})); - ui.close(); - } - } - } - }); - let _ = Align2::CENTER_CENTER; - }); + } + } + }); } /// The appearance button's icon and the mode's name for its tooltip. @@ -241,3 +258,16 @@ pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static st AppearanceMode::Dark => (Icon::Moon, "Dark Mode"), } } + +/// Tooltip for the warning cloud icon: `n` changes held in memory because saving failed with `e`. +fn unsaved_tip(n: usize, e: &str) -> String { + let s = crate::i18n::plural_s(n); + let it = if n == 1 { "it" } else { "them" }; + crate::i18n::tr_format!( + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.", + s, + it, + e = e, + n = n + ) +} diff --git a/crates/ui-egui/src/render/thumbnail_tests.rs b/crates/ui-egui/src/render/thumbnail_tests.rs index eaf4e8f2f..2fcd6c5ab 100644 --- a/crates/ui-egui/src/render/thumbnail_tests.rs +++ b/crates/ui-egui/src/render/thumbnail_tests.rs @@ -774,66 +774,81 @@ fn undo_to_existing_texture_supersedes_pending_edit() { assert_eq!(app.renderer.thumb(PhotoId(1)).unwrap().pixels.as_ref().unwrap().pixels, original.pixels); } -#[test] -fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { - let (mut app, work, ctx) = fixture(); +/// First request: other buckets must not replace the pending job. Returns the pending key. +fn thumb_buckets_pending_request(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, ctx: &egui::Context) -> u64 { let id = PhotoId(1); let slot = Slot::Thumb(id); // A print page's cells show the same photo at different sizes. Before any texture arrives, // each frame's other bucket used to replace (and restart) the pending job. - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); let built = app.renderer.thumb_jobs_built; assert_eq!(built, 1); - let (submitted, pending) = take_job(&work); + let (submitted, pending) = take_job(work); assert_eq!(submitted, slot); for _ in 0..12 { - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 8); - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 8); + crate::panels::grid::request_thumb(app, id, 256, 10); } assert_eq!(app.renderer.thumb_jobs_built, built, "another bucket must not replace the pending thumbnail"); assert!(work.borrow().jobs.is_empty(), "the pending render is not restarted"); assert_eq!(app.renderer.pending[&slot].0, pending.key); let pending_key = pending.key; - finish(&mut app, &work, &ctx, slot, pending.run()); + finish(app, work, ctx, slot, pending.run()); assert_eq!(app.renderer.textures[&slot].key, pending_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 256, "the completed request's bucket is remembered"); assert!(!app.renderer.is_pending(slot)); + pending_key +} +/// A larger bucket still upgrades; smaller cells wait behind it. Returns the larger key. +fn thumb_buckets_larger_upgrades( + app: &mut crate::LightcraftApp, + work: &std::rc::Rc>, + ctx: &egui::Context, + pending_key: u64, +) -> u64 { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // With a completed 256 thumbnail, the larger cell asks for its bucket once; the smaller // cells then wait behind it instead of replacing it back every frame. let built = app.renderer.thumb_jobs_built; - crate::panels::grid::request_thumb(&mut app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); assert_eq!(app.renderer.thumb_jobs_built, built + 1, "a larger bucket still upgrades"); - let (submitted, larger) = take_job(&work); + let (submitted, larger) = take_job(work); assert_eq!(submitted, slot); assert_ne!(larger.key, pending_key); let larger_key = app.renderer.pending[&slot].0; assert_eq!(larger_key, larger.key); for _ in 0..12 { - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); } assert_eq!(app.renderer.thumb_jobs_built, built + 1, "smaller cells keep the larger render in flight"); assert!(work.borrow().jobs.is_empty(), "the larger render is not restarted"); assert_eq!(app.renderer.pending[&slot].0, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512, "the in-flight bucket is still the larger one"); - finish(&mut app, &work, &ctx, slot, larger.run()); + finish(app, work, ctx, slot, larger.run()); assert_eq!(app.renderer.textures[&slot].key, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512); assert!(!app.renderer.is_pending(slot)); + larger_key +} +fn thumb_buckets_largest_serves_smaller(app: &mut crate::LightcraftApp, work: &std::rc::Rc>, larger_key: u64) { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // Once the largest visible bucket is complete, every smaller cell reuses those pixels. // The 8x8 fixture texture cannot show its requested edge, so the remembered bucket, key // and photo identity are what prove no render was built. Repeated frames at all sizes // (the old bug alternated 256/512 forever) must settle with nothing further requested. let built = app.renderer.thumb_jobs_built; for _ in 0..24 { - crate::panels::grid::request_thumb(&mut app, id, 256, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 10); - crate::panels::grid::request_thumb(&mut app, id, 512, 10); - crate::panels::grid::request_thumb(&mut app, id, 384, 8); + crate::panels::grid::request_thumb(app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 384, 10); + crate::panels::grid::request_thumb(app, id, 512, 10); + crate::panels::grid::request_thumb(app, id, 384, 8); } assert_eq!(app.renderer.thumb_jobs_built, built, "completed larger thumbnails serve smaller cells"); assert!(work.borrow().jobs.is_empty()); @@ -841,7 +856,16 @@ fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { assert_eq!(app.renderer.textures[&slot].key, larger_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 512); assert_eq!(app.renderer.thumb_inputs[&id].2, larger_key); +} +fn thumb_buckets_edit_requests_again( + app: &mut crate::LightcraftApp, + work: &std::rc::Rc>, + ctx: &egui::Context, + larger_key: u64, +) { + let id = PhotoId(1); + let slot = Slot::Thumb(id); // The renderer remembers the photo through a `Weak` only. That is enough for // `Catalog`'s `Arc::make_mut` to copy the photo on edit (std: an existing weak pointer // forces a new allocation even with a single strong reference), so the identity below is @@ -861,18 +885,27 @@ fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { assert_eq!(remembered.strong_count(), 0, "the renderer's Weak now points at the old, unowned allocation"); assert_eq!(Arc::strong_count(current), 1, "the edit left one strong owner; no extra Arc was needed"); let built = app.renderer.thumb_jobs_built; - crate::panels::grid::request_thumb(&mut app, id, 256, 10); + crate::panels::grid::request_thumb(app, id, 256, 10); assert_eq!(app.renderer.thumb_jobs_built, built + 1, "an edited photo is requested again"); - let (submitted, edited) = take_job(&work); + let (submitted, edited) = take_job(work); assert_eq!(submitted, slot); assert_ne!(edited.key, larger_key); let edited_key = edited.key; - finish(&mut app, &work, &ctx, slot, edited.run()); + finish(app, work, ctx, slot, edited.run()); assert_eq!(app.renderer.textures[&slot].key, edited_key); assert_eq!(app.renderer.thumb_inputs[&id].1, 256); assert!(!app.renderer.is_pending(slot)); } +#[test] +fn cells_at_different_thumbnail_buckets_keep_one_pending_request() { + let (mut app, work, ctx) = fixture(); + let pending_key = thumb_buckets_pending_request(&mut app, &work, &ctx); + let larger_key = thumb_buckets_larger_upgrades(&mut app, &work, &ctx, pending_key); + thumb_buckets_largest_serves_smaller(&mut app, &work, larger_key); + thumb_buckets_edit_requests_again(&mut app, &work, &ctx, larger_key); +} + /// The remembered larger bucket is not a picture by itself: once its texture is evicted, a /// smaller cell must render again rather than trust the stale record. #[test] diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 8eb13aa85..cc0802248 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Session, Value, pick}; pub type PickFiles = Box Vec>; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c36bcec98..2f0b07d45 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -386,10 +386,22 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() { return; } + let (fire, aliased) = pressed_commands(app, ctx); + for (id, params) in aliased { + fire_alias(app, ctx, id, params); + } + let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect"); + for f in fire { + fire_command(app, ctx, &f, rotate_fires); + } +} + +/// The keys pressed this frame: the commands to fire, and the aliases (command and params) to run. +fn pressed_commands(app: &LightcraftApp, ctx: &egui::Context) -> (Vec, Vec<(&'static str, serde_json::Value)>) { let mut fire: Vec = Vec::new(); // shortcuts the native menu bar handles (it consumes those key presses itself) let native = |sc: &str| app.native_shortcuts.contains(sc); - let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); + let mut aliased: Vec<(&'static str, serde_json::Value)> = Vec::new(); let keymap = &app.ui.settings.keymap; let grid = library_grid(app); // The grid navigation keys act on the photos in view, so they wait while the full-screen preview @@ -433,281 +445,342 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { aliased.push((id, serde_json::from_str(params).unwrap_or_default())); } } - // rating 0-5, colour labels 6-9 (with Shift: and advance) - for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { - if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) { - fire.push(format!("rate:{n}:0")); - } - if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) { - fire.push(format!("rate:{n}:1")); - } - } - for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { - if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) { - fire.push(format!("label:{label}")); - } - } - if grid_navigation { - // Home / End: the first / last photo of the view - for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { - if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { - fire.push(id.to_string()); - } - } - // Shift+← / Shift+→: the previous / next photo of the view joins the selection - for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { - if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { - fire.push(format!("grid:extend:{step}")); - } - } - } + fixed_photo_keys(i, &native, &taken, grid_navigation, &mut fire); }); - use crate::panels::compare; - // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or - // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) - let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| { - compare::target_active(app, &mut params); - let was = app.session.active(); - let ok = app.run(id, params).is_ok(); - if ok && (advance || app.ui.auto_advance) { - compare::advance(app, was); + (fire, aliased) +} + +/// The fixed photo keys pressed: ratings 0-5, colour labels 6-9 (with Shift: and advance), and in a grid the +/// navigation keys. Keys the user gave to a command (`taken`) and the native menu's (`native`) are left alone. +fn fixed_photo_keys(i: &egui::InputState, native: &dyn Fn(&str) -> bool, taken: &[(Modifiers, Key)], grid_navigation: bool, fire: &mut Vec) { + // rating 0-5, colour labels 6-9 (with Shift: and advance) + for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { + if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) { + fire.push(format!("rate:{n}:0")); } - }; - for (id, params) in aliased { - // a slideshow: Space pauses / resumes - if id == "view.zoomToggle" && app.ui.slideshow.is_some() { - if let Ok(v) = app.run("slideshow.togglePause", json!({})) { - app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" })); - } - continue; - } - // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, - // `advance` moves to the next candidate there - if matches!(id, "photo.flag" | "photo.rate" | "photo.label") { - let mut params = params; - let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false); - if let Some(o) = params.as_object_mut() { - o.remove("advance"); - } - cull(app, id, params, advance); - } else if let Err(e) = app.run(id, params) - && matches!(id, "app.export" | "app.exportPrevious") - { - // an export that can't start (e.g. no folder) says why instead of doing nothing - app.toast(ctx, e); + if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) { + fire.push(format!("rate:{n}:1")); } } - let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect"); - for f in fire { - // a slideshow: ←/→ step (a cut) - if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") { - let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); - continue; + for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { + if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(format!("label:{label}")); } - if let Some(rest) = f.strip_prefix("rate:") { - let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); - cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); - let label = if n == "0" { - crate::i18n::tr("Rating cleared").to_string() - } else { - crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) - }; - app.toast(ctx, label); - } else if matches!(f.as_str(), "photo.decreaseRating" | "photo.increaseRating") { - cull(app, &f, json!({}), false); - } else if let Some(l) = f.strip_prefix("label:") { - cull(app, "photo.label", json!({"label": l}), false); - } else if f == "panel.presets" && library_grid(app) { - // Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere. - cull(app, "photo.flag", json!({"flag": "pick"}), true); - } else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { - // S in a grid: expand / collapse the stack (Lightroom's Library binding) - let _ = app.run("stack.toggle", json!({})); - } else if f == "grid:first" || f == "grid:last" { - // Home / End in a grid: make the first / last photo of the view the active, selected one - let vis = app.session.visible_cloned(); - let edge = if f == "grid:first" { vis.first() } else { vis.last() }; - if let Some(id) = edge { - let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); - } - } else if let Some(step) = f.strip_prefix("grid:extend:") { - // Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and - // becomes the active one. At either end of the view the key does nothing. - let step = step.parse::().unwrap_or(0); - let vis = app.session.visible_cloned(); - let neighbour = app - .session - .active() - .and_then(|active| vis.iter().position(|p| *p == active)) - .and_then(|pos| i64::try_from(pos).ok()) - .and_then(|pos| pos.checked_add(step)) - .and_then(|pos| usize::try_from(pos).ok()) - .and_then(|pos| vis.get(pos)); - if let Some(id) = neighbour { - let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); - } - } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { - let d = if f == "library.next" { 1 } else { -1 }; - let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; - } else if matches!(f.as_str(), "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") { - // `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on) - let active = app.session.active(); - cull(app, &f, json!({}), false); - let flag = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag); - match flag { - Some(lightcraft_catalog::Flag::Pick) => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - Some(lightcraft_catalog::Flag::Reject) => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - Some(lightcraft_catalog::Flag::None) => app.toast(ctx, crate::i18n::tr("Unflagged")), - None => {} - } - } else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { - cull(app, &f, json!({}), false); - match f.as_str() { - "photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - "photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - _ => app.toast(ctx, crate::i18n::tr("Unflagged")), - } + } + if !grid_navigation { + return; + } + // Home / End: the first / last photo of the view + for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { + if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(id.to_string()); + } + } + // Shift+← / Shift+→: the previous / next photo of the view joins the selection + for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { + if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { + fire.push(format!("grid:extend:{step}")); + } + } +} + +/// Rating/flag/label keys: in Compare/Survey they act on the active photo only; `advance` (Shift+key) or +/// Auto Advance then moves on (next candidate in Compare, next photo elsewhere). +fn cull(app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool) { + use crate::panels::compare; + compare::target_active(app, &mut params); + let was = app.session.active(); + let ok = app.run(id, params).is_ok(); + if ok && (advance || app.ui.auto_advance) { + compare::advance(app, was); + } +} + +/// Run a fixed alias (a second key for a command). +fn fire_alias(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: serde_json::Value) { + // a slideshow: Space pauses / resumes + if id == "view.zoomToggle" && app.ui.slideshow.is_some() { + if let Ok(v) = app.run("slideshow.togglePause", json!({})) { + app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" })); + } + return; + } + // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, + // `advance` moves to the next candidate there + if matches!(id, "photo.flag" | "photo.rate" | "photo.label") { + let mut params = params; + let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false); + if let Some(o) = params.as_object_mut() { + o.remove("advance"); + } + cull(app, id, params, advance); + } else if let Err(e) = app.run(id, params) + && matches!(id, "app.export" | "app.exportPrevious") + { + // an export that can't start (e.g. no folder) says why instead of doing nothing + app.toast(ctx, e); + } +} + +/// The toast naming a flag. +fn flag_toast(app: &mut LightcraftApp, ctx: &egui::Context, flag: lightcraft_catalog::Flag) { + use lightcraft_catalog::Flag; + app.toast( + ctx, + crate::i18n::tr(match flag { + Flag::Pick => "Flagged as Pick", + Flag::Reject => "Flagged as Reject", + Flag::None => "Unflagged", + }), + ); +} + +/// Run one fired command. +fn fire_command(app: &mut LightcraftApp, ctx: &egui::Context, f: &str, rotate_fires: bool) { + // a slideshow: ←/→ step (a cut) + if app.ui.slideshow.is_some() && matches!(f, "library.next" | "library.previous") { + let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); + return; + } + if fire_rating_key(app, ctx, f) || fire_grid_key(app, f) || fire_flag_key(app, ctx, f) { + return; + } + // in the full-screen preview (no panels) I cycles the info overlay instead + if f == "panel.info" && app.ui.fullscreen { + let _ = app.run("view.infoOverlay", json!({})); + return; + } + if (f == "photo.delete" && delete_key(app, ctx)) || (f == "tool.brush" && brush_key(app, ctx)) || context_key_skipped(app, f, rotate_fires) { + return; + } + if matches!(f, "library.up" | "library.down") { + up_down_key(app, ctx, f); + return; + } + // an export that can't start (e.g. no folder) says why instead of doing nothing + if let Err(e) = app.run(f, json!({})) + && matches!(f, "app.export" | "app.exportPrevious") + { + app.toast(ctx, e); + } + match f { + "photo.pick" => flag_toast(app, ctx, lightcraft_catalog::Flag::Pick), + "photo.reject" => flag_toast(app, ctx, lightcraft_catalog::Flag::Reject), + "photo.unflag" => flag_toast(app, ctx, lightcraft_catalog::Flag::None), + "edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")), + "edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")), + _ => {} + } +} + +/// Rating and colour label keys; whether `f` was one. +fn fire_rating_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) -> bool { + if let Some(rest) = f.strip_prefix("rate:") { + let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); + cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); + let label = if n == "0" { + crate::i18n::tr("Rating cleared").to_string() } else { - // in the full-screen preview (no panels) I cycles the info overlay instead - if f == "panel.info" && app.ui.fullscreen { - let _ = app.run("view.infoOverlay", json!({})); - continue; + crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) + }; + app.toast(ctx, label); + } else if matches!(f, "photo.decreaseRating" | "photo.increaseRating") { + cull(app, f, json!({}), false); + } else if let Some(l) = f.strip_prefix("label:") { + cull(app, "photo.label", json!({"label": l}), false); + } else { + return false; + } + true +} + +/// The keys of a grid or of Compare / Survey that move the active photo or the selection; whether `f` was one. +fn fire_grid_key(app: &mut LightcraftApp, f: &str) -> bool { + use crate::panels::compare; + if f == "panel.presets" && library_grid(app) { + // Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere. + cull(app, "photo.flag", json!({"flag": "pick"}), true); + } else if f == "view.softProof" && library_grid(app) { + // S in a grid: expand / collapse the stack (Lightroom's Library binding) + let _ = app.run("stack.toggle", json!({})); + } else if f == "grid:first" || f == "grid:last" { + // Home / End in a grid: make the first / last photo of the view the active, selected one + let vis = app.session.visible_cloned(); + let edge = if f == "grid:first" { vis.first() } else { vis.last() }; + if let Some(id) = edge { + let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); + } + } else if let Some(step) = f.strip_prefix("grid:extend:") { + extend_selection(app, step.parse::().unwrap_or(0)); + } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { + let d = if f == "library.next" { 1 } else { -1 }; + let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; + } else { + return false; + } + true +} + +/// Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and +/// becomes the active one. At either end of the view the key does nothing. +fn extend_selection(app: &mut LightcraftApp, step: i64) { + let vis = app.session.visible_cloned(); + let neighbour = app + .session + .active() + .and_then(|active| vis.iter().position(|p| *p == active)) + .and_then(|pos| i64::try_from(pos).ok()) + .and_then(|pos| pos.checked_add(step)) + .and_then(|pos| usize::try_from(pos).ok()) + .and_then(|pos| vis.get(pos)); + if let Some(id) = neighbour { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); + } +} + +/// The flag keys; whether `f` was one. +fn fire_flag_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) -> bool { + if matches!(f, "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") { + // `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on) + let active = app.session.active(); + cull(app, f, json!({}), false); + if let Some(flag) = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag) { + flag_toast(app, ctx, flag); + } + true + } else if matches!(f, "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { + cull(app, f, json!({}), false); + let flag = match f { + "photo.pick" => lightcraft_catalog::Flag::Pick, + "photo.reject" => lightcraft_catalog::Flag::Reject, + _ => lightcraft_catalog::Flag::None, + }; + flag_toast(app, ctx, flag); + true + } else { + false + } +} + +/// Delete acts on what's being edited: the active mask in the Masking panel; never the photo while retouching (spots +/// are removed from their own panel). Whether the key was used up. +fn delete_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + use crate::state::RightPanel as R; + match app.ui.right { + R::Masking => { + if app.session.active_mask.is_some() { + let _ = app.run("mask.delete", json!({})); } - // Delete acts on what's being edited: the active mask in the Masking panel; never the - // photo while retouching (spots are removed from their own panel). - if f == "photo.delete" { - use crate::state::RightPanel as R; - match app.ui.right { - R::Masking => { - if app.session.active_mask.is_some() { - let _ = app.run("mask.delete", json!({})); - } - continue; - } - R::Remove => { - if app.session.active_spot.is_some() { - let _ = app.run("spot.delete", json!({})); - } - continue; - } - R::Crop if app.ui.tool == "guidedUpright" => { - // Delete removes the selected guide alone; with none selected it removes - // nothing (the photo itself is never deleted while the tool is open) - if let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) { - if photo == active.0 - && let Some(d) = app.session.develop_of(active) - && i < d.geometry.guides.len() - { - let guides: Vec<[f64; 4]> = - d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect(); - let _ = app.run("geometry.guides", json!({"guides": guides})); - } - app.ui.selected_guide = None; - } - continue; - } - R::RedEye => continue, - _ => {} - } - // in an album, Delete takes the photos out of the album only, as in Lightroom: they - // stay in the library and their folder (issue #1805) - if crate::menus::delete_in_album(app, ctx) { - continue; - } - if crate::menus::confirm_delete(app) { - continue; - } + return true; + } + R::Remove => { + if app.session.active_spot.is_some() { + let _ = app.run("spot.delete", json!({})); } - // B: the brush while editing; in the grids, add to the target album (Quick Collection) - if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { - if let Ok(r) = app.run("album.toggleTarget", json!({})) { - let n = app.session.selected().len(); - let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); - let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); - app.toast( - ctx, - if r["added"] == true { - crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what) - } else { - crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what) - }, - ); - } - continue; - } - // X is both reject (library) and swap crop aspect (crop tool). When both bindings fire, - // swap once: twice would put the crop straight back (issue #1866). - if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { - if !rotate_fires { - let _ = app.run("crop.rotateAspect", json!({})); - } - continue; - } - if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { - continue; - } - // A is both Visualize Spots and the crop aspect lock: each key only in its own context - if f == "view.visualizeSpots" && app.ui.right == crate::state::RightPanel::Crop { - continue; - } - if f == "crop.lockAspect" && app.ui.right != crate::state::RightPanel::Crop { - continue; - } - // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) - if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() { - let _ = app.run("spot.refreshSource", json!({})); - continue; - } - // Shift+O cycles the mask overlay colour while masking (the crop overlay elsewhere) - if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking { - let _ = app.run("view.maskOverlayColor", json!({})); - continue; - } - // while cropping: O cycles the guides, Shift+O their orientation - if app.ui.right == crate::state::RightPanel::Crop { - let crop_key = match f.as_str() { - "view.maskOverlay" => Some(("view.cropOverlay", json!({}))), - "view.cropOverlay" => Some(("view.cropOverlayOrientation", json!({}))), - _ => None, - }; - if let Some((cmd, p)) = crop_key { - let _ = app.run(cmd, p); - continue; - } - } - // Up / Down in the grids go to the cell above / below (the filmstrip steps one photo) - if matches!(f.as_str(), "library.up" | "library.down") { - // over a slider the arrows nudge it (widgets::slider takes the key) - let over_slider = - ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p))); - if over_slider { - continue; - } - match crate::panels::grid::row_distance(app, f == "library.down") { - Some(0) => {} - Some(rows) => { - let _ = app.run(&f, json!({"rows": rows})); - } - None => { - let _ = app.run(&f, json!({})); - } - } - continue; - } - // an export that can't start (e.g. no folder) says why instead of doing nothing - if let Err(e) = app.run(&f, json!({})) - && matches!(f.as_str(), "app.export" | "app.exportPrevious") - { - app.toast(ctx, e); - } - match f.as_str() { - "photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), - "photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), - "photo.unflag" => app.toast(ctx, crate::i18n::tr("Unflagged")), - "edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")), - "edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")), - _ => {} + return true; + } + R::Crop if app.ui.tool == "guidedUpright" => { + delete_selected_guide(app); + return true; + } + R::RedEye => return true, + _ => {} + } + // in an album, Delete takes the photos out of the album only, as in Lightroom: they + // stay in the library and their folder (issue #1805) + crate::menus::delete_in_album(app, ctx) || crate::menus::confirm_delete(app) +} + +/// Delete removes the selected guide alone; with none selected it removes nothing (the photo itself is never deleted +/// while the tool is open). +fn delete_selected_guide(app: &mut LightcraftApp) { + let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) else { return }; + if photo == active.0 + && let Some(d) = app.session.develop_of(active) + && i < d.geometry.guides.len() + { + let guides: Vec<[f64; 4]> = d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect(); + let _ = app.run("geometry.guides", json!({"guides": guides})); + } + app.ui.selected_guide = None; +} + +/// B: the brush while editing; in the grids, add to the target album (Quick Collection). Whether the key was used up. +fn brush_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + if !library_grid(app) { + return false; + } + if let Ok(r) = app.run("album.toggleTarget", json!({})) { + let n = app.session.selected().len(); + let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); + let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); + app.toast( + ctx, + if r["added"] == true { + crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what) + } else { + crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what) + }, + ); + } + true +} + +/// Keys that two bindings share, and that mean something only in one panel: whether `f` was used up (or ignored) +/// here, so nothing else runs for it. +fn context_key_skipped(app: &mut LightcraftApp, f: &str, rotate_fires: bool) -> bool { + use crate::state::RightPanel as R; + let right = app.ui.right; + match f { + // X is both reject (library) and swap crop aspect (crop tool). When both bindings fire, + // swap once: twice would put the crop straight back (issue #1866). + "photo.reject" if right == R::Crop => { + if !rotate_fires { + let _ = app.run("crop.rotateAspect", json!({})); } + true + } + "crop.rotateAspect" => right != R::Crop, + // A is both Visualize Spots and the crop aspect lock: each key only in its own context + "view.visualizeSpots" => right == R::Crop, + "crop.lockAspect" => right != R::Crop, + // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) + "view.filmstrip" if right == R::Remove && app.session.active_spot.is_some() => { + let _ = app.run("spot.refreshSource", json!({})); + true + } + // Shift+O cycles the mask overlay colour while masking (the crop overlay elsewhere) + "view.cropOverlay" if right == R::Masking => { + let _ = app.run("view.maskOverlayColor", json!({})); + true + } + // while cropping: O cycles the guides, Shift+O their orientation + "view.maskOverlay" if right == R::Crop => { + let _ = app.run("view.cropOverlay", json!({})); + true + } + "view.cropOverlay" if right == R::Crop => { + let _ = app.run("view.cropOverlayOrientation", json!({})); + true + } + _ => false, + } +} + +/// Up / Down in the grids go to the cell above / below (the filmstrip steps one photo). +fn up_down_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) { + // over a slider the arrows nudge it (widgets::slider takes the key) + let over_slider = ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p))); + if over_slider { + return; + } + match crate::panels::grid::row_distance(app, f == "library.down") { + Some(0) => {} + Some(rows) => { + let _ = app.run(f, json!({"rows": rows})); + } + None => { + let _ = app.run(f, json!({})); } } } diff --git a/crates/ui-egui/src/state/dialog.rs b/crates/ui-egui/src/state/dialog.rs index edc4946cd..7f9226b6e 100644 --- a/crates/ui-egui/src/state/dialog.rs +++ b/crates/ui-egui/src/state/dialog.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything /// but crop, masks, spots and red eye. diff --git a/crates/ui-egui/src/state/settings.rs b/crates/ui-egui/src/state/settings.rs index 2d748f5a7..fff943b4c 100644 --- a/crates/ui-egui/src/state/settings.rs +++ b/crates/ui-egui/src/state/settings.rs @@ -1,7 +1,7 @@ use super::view::LoupeInfo; #[cfg(test)] use super::view::{InfoOverlay, LoupeInfoField}; -use super::*; +use super::{Deserialize, Serialize}; /// Which view the app opens in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/state/view.rs b/crates/ui-egui/src/state/view.rs index 1bbfd4349..611002647 100644 --- a/crates/ui-egui/src/state/view.rs +++ b/crates/ui-egui/src/state/view.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/ui-egui/src/tests/lifecycle_037.rs b/crates/ui-egui/src/tests/lifecycle_037.rs index 3c4e8009b..bb6f9d860 100644 --- a/crates/ui-egui/src/tests/lifecycle_037.rs +++ b/crates/ui-egui/src/tests/lifecycle_037.rs @@ -121,9 +121,20 @@ fn resizable_side_stores_panel_state_under_the_parent_salted_key() { let ctx = egui::Context::default(); let raw = raw(egui::vec2(1000.0, 700.0)); frame(&ctx, raw, |ui| { - crate::panels::resizable_side(ui, true, "side-test", egui::Frame::NONE, 260.0, crate::state::LEFT_WIDTH, 300.0, |ui| { - ui.label("side"); - }); + crate::panels::resizable_side( + ui, + crate::panels::SidePanel { + left: true, + id: "side-test", + frame: egui::Frame::NONE, + width: 260.0, + limits: crate::state::LEFT_WIDTH, + reserve: 300.0, + }, + |ui| { + ui.label("side"); + }, + ); }); let modern = ctx.viewport_id().root_ui_id().with("side-test"); assert!( diff --git a/crates/ui-egui/src/tests/open_lightroom.rs b/crates/ui-egui/src/tests/open_lightroom.rs index d97f9d0e8..684883aa0 100644 --- a/crates/ui-egui/src/tests/open_lightroom.rs +++ b/crates/ui-egui/src/tests/open_lightroom.rs @@ -22,8 +22,16 @@ fn names(dir: &std::path::Path) -> Vec { v } -#[test] -fn open_library_on_a_lightroom_catalog_offers_its_import() { +struct Fx { + dir: std::path::PathBuf, + lib: std::path::PathBuf, + lr: std::path::PathBuf, + lrcat: std::path::PathBuf, + before: Vec, + photos: usize, +} + +fn setup() -> (Headless, Fx) { let dir = std::env::temp_dir().join(format!("lc-ui-openlrcat-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let lib = dir.join("LightCraft Library"); @@ -52,7 +60,12 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { }; let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0); h.step(); + (h, Fx { dir, lib, lr, lrcat, before, photos }) +} +fn offer_and_cancel(h: &mut Headless, fx: &Fx) { + let Fx { lib, lr, lrcat, before, photos, .. } = fx; + let (photos, lib, lr, lrcat, before) = (*photos, lib.clone(), lr.clone(), lrcat.clone(), before.clone()); // File ▸ Open Library…, the Lightroom folder chosen in the folder dialog let r = h.app.run("app.openLibrary", json!({})).unwrap(); assert_eq!(r["opened"], false, "{r}"); @@ -64,7 +77,7 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { "{:?}", h.app.ui.dialog ); - assert!(has(&h, "button:dialogOk") && has(&h, "button:dialogCancel")); + assert!(has(h, "button:dialogOk") && has(h, "button:dialogCancel")); // the library open before is still open, and nothing was made in Lightroom's folder assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); assert_eq!(h.app.session.catalog.len(), photos); @@ -79,7 +92,10 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { h.step(); assert!(h.app.ui.dialog.is_none()); assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); +} +fn reoffer_import_and_merge(h: &mut Headless, fx: &Fx) { + let (lib, lr, lrcat, before) = (fx.lib.clone(), fx.lr.clone(), fx.lrcat.clone(), fx.before.clone()); // a previews folder given by path (control channel, agents): the same offer let r = h.app.run("app.openLibrary", json!({"path": lr.join("Lightroom Catalog Previews.lrdata").to_string_lossy()})).unwrap(); assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}"); @@ -108,6 +124,13 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { assert_eq!(last["path"], json!(lrcat), "{last}"); assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); assert_eq!(names(&lr), before, "the catalog is only read"); +} + +#[test] +fn open_library_on_a_lightroom_catalog_offers_its_import() { + let (mut h, fx) = setup(); + offer_and_cancel(&mut h, &fx); + reoffer_import_and_merge(&mut h, &fx); drop(h); - let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&fx.dir); } diff --git a/crates/ui-egui/src/tests/preview_limit.rs b/crates/ui-egui/src/tests/preview_limit.rs index 07a30c8a5..3687199c9 100644 --- a/crates/ui-egui/src/tests/preview_limit.rs +++ b/crates/ui-egui/src/tests/preview_limit.rs @@ -840,6 +840,72 @@ mod in_the_loupe { } } + fn drag_first_window(h: &mut Headless, work: &Rc>, id: lightcraft_catalog::PhotoId) -> RenderJob { + let first = next_region_job(h, work); + let view = h.app.region_view.expect("Detail asked for a window"); + assert_eq!(view.photo, id); + assert_eq!(view.full, (WIDTH as usize, HEIGHT as usize), "at 400 % the window's frame is the photo's own pixels"); + assert!(view.window.w >= 1300 && view.window.h >= 700, "the window covers the canvas: {:?}", view.window); + assert!(view.window.x + view.window.w <= view.full.0 && view.window.y + view.window.h <= view.full.1, "inside the frame: {:?}", view.window); + assert_eq!(first.request.window, Some(view.window), "the job is the window on screen"); + assert_eq!((first.request.max_w, first.request.max_h), view.full, "the job renders the frame the view uses"); + assert_eq!(first.request.quality, lightcraft_pipeline::Quality::Draft, "a drag drafts the window"); + assert_eq!(first.level, SourceLevel::Full); + assert!(matches!(&first.source, SourceRef::File { .. }), "the first window decodes the real file"); + assert_ne!(first.request_id, 0, "the renderer gave the request its own identity"); + first + } + + /// An edit during the first decode waits; the first result is accepted with its original. + fn drag_accepts_first_original( + h: &mut Headless, + work: &Rc>, + id: lightcraft_catalog::PhotoId, + first: RenderJob, + ) -> (u64, u64) { + // An edit changes the desired window key while the first original is still held. + // Detail must defer a second decode until this first result has populated the cache. + let (first_key, first_request) = (first.key, first.request_id); + let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + assert_eq!(reply["ok"], true); + h.step(); + assert!(work.borrow().jobs.is_empty(), "a changed window waits for the first original"); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(first_key)); + assert_eq!(h.app.session.memory_report().full_source.bytes, 0); + + deliver(work, Slot::Region, first.run()); + h.step(); + assert!(h.app.region_view.is_some()); + assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(first_key), "the first window arrived"); + assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); + assert!(h.app.session.media.has_source(id, SourceLevel::Full), "the original was accepted before any supersession"); + (first_key, first_request) + } + + fn drag_later_windows_reuse_original(h: &mut Headless, work: &Rc>, first: (u64, u64), discarded: u64) { + let (first_key, first_request) = first; + let second = next_region_job(h, work); + let (second_key, second_request) = (second.key, second.request_id); + assert_ne!(second_key, first_key); + assert_ne!(second_request, first_request); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(second_key), "the newer window is pending"); + assert!(matches!(&second.source, SourceRef::Loaded(_)), "the newer window reuses the accepted original"); + + let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T); + assert_eq!(reply["ok"], true); + let third = next_region_job(h, work); + assert_ne!(third.key, second_key); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key)); + + deliver(work, Slot::Region, second.run()); + h.step(); + assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second_key), "the superseded draft still reached the screen"); + assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key), "the newest request is still pending"); + assert_eq!(h.app.renderer.discarded, discarded, "the superseded draft was accepted, not discarded"); + assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); + assert!(matches!(&third.source, SourceRef::Loaded(_)), "the pending window still reuses the original"); + } + // Given a 400 % view of a real 24 MP photo, when the first window is decoded and accepted and // later drag ticks supersede each other, then the original stays in the cache and every later // window is cut from it. The first accepted result is the actual Detail wiring's job: the @@ -856,57 +922,9 @@ mod in_the_loupe { let discarded = h.app.renderer.discarded; h.app.ui.zoom = crate::state::Zoom::Percent(400.0); h.app.session.begin_interaction("Exposure").expect("a drag"); - - let first = next_region_job(&mut h, &work); - let (first_key, first_request) = (first.key, first.request_id); - let view = h.app.region_view.expect("Detail asked for a window"); - assert_eq!(view.photo, id); - assert_eq!(view.full, (WIDTH as usize, HEIGHT as usize), "at 400 % the window's frame is the photo's own pixels"); - assert!(view.window.w >= 1300 && view.window.h >= 700, "the window covers the canvas: {:?}", view.window); - assert!(view.window.x + view.window.w <= view.full.0 && view.window.y + view.window.h <= view.full.1, "inside the frame: {:?}", view.window); - assert_eq!(first.request.window, Some(view.window), "the job is the window on screen"); - assert_eq!((first.request.max_w, first.request.max_h), view.full, "the job renders the frame the view uses"); - assert_eq!(first.request.quality, lightcraft_pipeline::Quality::Draft, "a drag drafts the window"); - assert_eq!(first.level, SourceLevel::Full); - assert!(matches!(&first.source, SourceRef::File { .. }), "the first window decodes the real file"); - assert_ne!(first_request, 0, "the renderer gave the request its own identity"); - - // An edit changes the desired window key while the first original is still held. - // Detail must defer a second decode until this first result has populated the cache. - let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); - assert_eq!(reply["ok"], true); - h.step(); - assert!(work.borrow().jobs.is_empty(), "a changed window waits for the first original"); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(first_key)); - assert_eq!(h.app.session.memory_report().full_source.bytes, 0); - - deliver(&work, Slot::Region, first.run()); - h.step(); - assert!(h.app.region_view.is_some()); - assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(first_key), "the first window arrived"); - assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); - assert!(h.app.session.media.has_source(id, SourceLevel::Full), "the original was accepted before any supersession"); - - let second = next_region_job(&mut h, &work); - let (second_key, second_request) = (second.key, second.request_id); - assert_ne!(second_key, first_key); - assert_ne!(second_request, first_request); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(second_key), "the newer window is pending"); - assert!(matches!(&second.source, SourceRef::Loaded(_)), "the newer window reuses the accepted original"); - - let reply = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T); - assert_eq!(reply["ok"], true); - let third = next_region_job(&mut h, &work); - assert_ne!(third.key, second_key); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key)); - - deliver(&work, Slot::Region, second.run()); - h.step(); - assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second_key), "the superseded draft still reached the screen"); - assert_eq!(h.app.renderer.wanted(Slot::Region), Some(third.key), "the newest request is still pending"); - assert_eq!(h.app.renderer.discarded, discarded, "the superseded draft was accepted, not discarded"); - assert!(h.app.session.memory_report().full_source.bytes >= WIDTH as usize * HEIGHT as usize * 3 * 4); - assert!(matches!(&third.source, SourceRef::Loaded(_)), "the pending window still reuses the original"); + let first = drag_first_window(&mut h, &work, id); + let keys = drag_accepts_first_original(&mut h, &work, id, first); + drag_later_windows_reuse_original(&mut h, &work, keys, discarded); } // The collector's accept branch on its own: a superseded window result still carries the diff --git a/crates/ui-egui/src/tests/slideshow.rs b/crates/ui-egui/src/tests/slideshow.rs index 01a468ed8..26c72c2cd 100644 --- a/crates/ui-egui/src/tests/slideshow.rs +++ b/crates/ui-egui/src/tests/slideshow.rs @@ -871,20 +871,8 @@ fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { h.settle(SETTLE); } -/// Settings ▸ Slideshow ▸ Templates: built-ins listed, hover previews on the selected photo -/// without changing anything, a click applies, and the user's own templates are saved, updated, -/// renamed and deleted from the browser. -#[test] -fn template_browser_previews_on_hover_applies_and_manages_user_templates() { +fn tpl_hover_previews_without_changes(h: &mut Headless) -> lightcraft_engine::slide_template::SlideLook { use lightcraft_engine::slide_template::SlideLook; - let mut h = grid(); - let dir = std::env::temp_dir().join(format!("lc-ui-sstpl-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - h.app.session.slide_templates_dir = Some(dir.clone()); - let first = h.app.session.visible_cloned()[0]; - run(&mut h, "library.select", json!({"ids": [first.0]})); - run(&mut h, "view.slideshowOptions", json!({})); - assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); let rect = |h: &Headless, w: &str| h.app.widgets.iter().find(|(id, _)| id == w).map(|(_, r)| *r).unwrap(); for w in [ @@ -896,7 +884,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { "field:slideshowTemplateName", "button:slideshowTemplateSave", ] { - assert!(has(&h, w), "{w}"); + assert!(has(h, w), "{w}"); } // hover: the preview shows Gallery Wall (its light grey wall in the margin), nothing changes let before = SlideLook::of(&h.app.session); @@ -915,42 +903,55 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert!(h.step_until(SETTLE, grey), "the hovered template on the selected photo"); assert_eq!(SlideLook::of(&h.app.session), before, "hovering changes nothing"); assert!(h.app.session.slide_templates_previous.is_none()); + before +} + +fn tpl_click_applies_and_saves_own(h: &mut Headless, dir: &std::path::Path) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // a click applies it - reveal(&mut h, "template:builtin:gallery-wall"); + reveal(h, "template:builtin:gallery-wall"); let r = h.request("ui.clickWidget", json!({"id": "template:builtin:gallery-wall"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(h.app.session.slide_overlays.rating_stars.enabled && h.app.session.slide_layout.stroke_border); - assert!(has(&h, "template:previous"), "Previous settings appear"); + assert!(has(h, "template:previous"), "Previous settings appear"); // save as a template of our own - reveal(&mut h, "field:slideshowTemplateName"); + reveal(h, "field:slideshowTemplateName"); let r = h.request("ui.clickWidget", json!({"id": "field:slideshowTemplateName"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.text", json!({"text": "Holiday / 2026"}), T); assert_eq!(r["ok"], true, "{r}"); - reveal(&mut h, "button:slideshowTemplateSave"); + reveal(h, "button:slideshowTemplateSave"); let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateSave"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(dir.join("Holiday _ 2026.json").exists(), "the separator never makes a folder"); h.step(); - assert!(has(&h, "template:user:Holiday _ 2026")); + assert!(has(h, "template:user:Holiday _ 2026")); +} + +fn tpl_update_from_menu(h: &mut Headless) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // update it from its menu with changed settings - run(&mut h, "slideshow.layout", json!({"backdropColor": "#123456"})); - reveal(&mut h, "template:user:Holiday _ 2026"); + run(h, "slideshow.layout", json!({"backdropColor": "#123456"})); + reveal(h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); for w in ["button:slideshowTemplateUpdate", "button:slideshowTemplateRename", "button:slideshowTemplateExport", "button:slideshowTemplateDelete"] { - assert!(has(&h, w), "{w}"); + assert!(has(h, w), "{w}"); } let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateUpdate"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert_eq!(h.app.session.slide_template("user:Holiday _ 2026").unwrap().look.layout.backdrop_color, [0x12, 0x34, 0x56]); +} + +fn tpl_rename_in_place(h: &mut Headless) { + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // rename it in place - reveal(&mut h, "template:user:Holiday _ 2026"); + reveal(h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -958,7 +959,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert_eq!(r["ok"], true, "{r}"); h.step(); h.step(); - assert!(has(&h, "field:slideshowTemplateRename")); + assert!(has(h, "field:slideshowTemplateRename")); let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.text", json!({"text": "Summer"}), T); @@ -967,9 +968,13 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert_eq!(r["ok"], true, "{r}"); h.step(); h.step(); - assert!(has(&h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::>()); + assert!(has(h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::>()); +} + +fn tpl_delete_then_back_to_previous(h: &mut Headless, dir: &std::path::Path, before: &lightcraft_engine::slide_template::SlideLook) { + use lightcraft_engine::slide_template::SlideLook; // delete it (after confirming) - reveal(&mut h, "template:user:Summer"); + reveal(h, "template:user:Summer"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Summer", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -983,12 +988,33 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert!(!dir.join("Summer.json").exists()); // back to the settings before Gallery Wall - reveal(&mut h, "template:previous"); + reveal(h, "template:previous"); let r = h.request("ui.clickWidget", json!({"id": "template:previous"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert_eq!(SlideLook::of(&h.app.session).overlays, before.overlays); h.settle(SETTLE); +} + +/// Settings ▸ Slideshow ▸ Templates: built-ins listed, hover previews on the selected photo +/// without changing anything, a click applies, and the user's own templates are saved, updated, +/// renamed and deleted from the browser. +#[test] +fn template_browser_previews_on_hover_applies_and_manages_user_templates() { + let mut h = grid(); + let dir = std::env::temp_dir().join(format!("lc-ui-sstpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + h.app.session.slide_templates_dir = Some(dir.clone()); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let before = tpl_hover_previews_without_changes(&mut h); + tpl_click_applies_and_saves_own(&mut h, &dir); + tpl_update_from_menu(&mut h); + tpl_rename_in_place(&mut h); + tpl_delete_then_back_to_previous(&mut h, &dir, &before); + h.settle(SETTLE); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 558edfe8d..89185d991 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -337,24 +337,9 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { /// scrub rather than a click that types a value. const SCRUB_START: f32 = 3.0; -/// The radius of a slider's hollow ring thumb. -const THUMB_RING: f32 = 7.0; - -/// A Lightroom slider row (label + value above a track with a hollow ring thumb). -/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment. -/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it, -/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth -/// as far). -pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { - let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); - let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover()); - let pad_l = 24.0; - let pad_r = 22.0; - let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0)); - let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); - let id = ui.scope_id().with(spec.id); - let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); +/// Which slider the vertical wheel edits: clicking or dragging a slider arms it (and disarms the others), leaving +/// its row or its focus disarms it. Returns whether this slider is armed, and whether the pointer is in its row. +fn update_wheel_arming(ui: &mut Ui, id: egui::Id, resp: &egui::Response, row: Rect, enabled: bool) -> (bool, bool) { // Keyboard focus survives leaving the row; vertical wheel edits need a fresh click or drag // in it (at most one slider is armed: `wheel_slider_key`). let wheel_id = id.with("wheel-rem"); @@ -394,21 +379,34 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ }); wheel_armed = false; } + (wheel_armed, pointer_in_row) +} + +/// The radius of a slider's hollow ring thumb. +const THUMB_RING: f32 = 7.0; + +/// A Lightroom slider row (label + value above a track with a hollow ring thumb). +/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment. +/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it, +/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth +/// as far). +pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover()); + let pad_l = 24.0; + let pad_r = 22.0; + let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0)); + let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0)); + let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); + let id = ui.scope_id().with(spec.id); + let g = SliderLayout { spec, value, enabled, id, track_rect, value_rect }; + let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); + let (wheel_armed, pointer_in_row) = update_wheel_arming(ui, id, &resp, row, enabled); // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); - // and its range, step and the value as shown ("+0.50", "5600") - let shown = shown_value(spec, value); - ui.ctx().accesskit_node_builder(resp.id, |node| { - node.set_min_numeric_value(spec.min); - node.set_max_numeric_value(spec.max); - node.set_numeric_value_step(spec.step); - node.set_value(shown); - if enabled { - node.add_action(egui::accesskit::Action::Increment); - node.add_action(egui::accesskit::Action::Decrement); - } - }); + describe_slider(ui, &g, &resp); // the name: double-click it to reset, as the thumb (a disabled slider's name is only text) let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() }); // say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do @@ -419,246 +417,41 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ register(ui, format!("slider:{}", spec.id), track_rect); // the value: click it to type an exact one, drag it sideways to scrub (over the label, so it // takes the click) - let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it"))); register(ui, format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp }; - let typing_id = id.with("typing"); // Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit. - let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id)); + let mut typing: Option = ui.data(|m| m.get_temp(g.typing_id())); // A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead // of rounding back to the same step every frame. - let scrub_id = id.with("scrub-pos"); - let mut scrub: Option = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None }; - let mut scrub_started = false; - if enabled - && scrub.is_none() - && value_resp.dragged() - && let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) - && (p.x - origin.x).abs() >= SCRUB_START - { - // a press on the value that moves sideways scrubs: the edit the press opened is dropped - typing = None; - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); - // the travel so far counts, so the value doesn't lag the pointer by the start distance - let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; - scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0)); - scrub_started = true; - } + let mut scrub: Option = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(g.scrub_id())) } else { None }; + let scrub_started = begin_scrub(ui, &g, &value_resp, &mut scrub, &mut typing); if scrub.is_none() { - ui.data_mut(|m| m.remove::(scrub_id)); - } - let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); - // Focus on the owned primary press before fast typing; retain the readout's click activation. - if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { - typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0, value_pressed)); - } - let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); - // A release over the value keeps the field the press opened, however long the button was held - // (a press held past egui's click time ends without a click). A release elsewhere cancels the - // gesture, and a press that scrubs already dropped the field above. - let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| value_rect.contains(p)); - if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { - typing = None; - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); + ui.data_mut(|m| m.remove::(g.scrub_id())); } + update_typing_gesture(ui, &g, &value_resp, &mut typing); let mut out = SliderOut::default(); - // value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) - let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); - let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); // the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest // of the track, which is clicked and dragged, gets no tooltip) - let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0; - if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) { + if enabled && !resp.dragged() && resp.hover_pos().is_some_and(|p| g.on_thumb(p)) { resp.clone().on_hover_text(reset_hint.as_str()); } - let mut v = value; - if let Some(mut pos) = scrub { - // scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel - // over the track's width (a tenth with Shift), on the control's steps - if !scrub_started && value_resp.dragged() { - let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; - let dx = ui.input(|i| i.pointer.delta().x); - pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0); - } - let step = spec.step.max(1e-9); - let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - v = nv; - } - out.drag_started = scrub_started; - if value_resp.drag_stopped() { - out.drag_stopped = true; - ui.data_mut(|m| m.remove::(scrub_id)); - } else { - ui.data_mut(|m| m.insert_temp(scrub_id, pos)); - } - } else if let Some((mut text, frames, pressed)) = typing.take() { - // Keep the owned opening release focused without restarting its draft or selection. - // egui gives that release's click to the readout, so the field under it gives up the focus - // whatever frame it lands on (issue #1362): the field takes it back after drawing too. - let opening_release = pressed && value_released; - if opening_release { - ui.memory_mut(|m| m.request_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); - } - let pressed = pressed && !value_released; - // a child over the value, so the field never moves the rows around it - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); - // the old value is selected when the field takes the focus, so typing replaces it - let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", spec.id), &mut text) - .font(t.font(12.5)) - .horizontal_align(egui::Align::RIGHT) - .width(value_rect.width()) - .margin(egui::Margin::ZERO) - .select_on_focus(true) - .begin_edit(frames == 0) - .show(&mut child); - if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { - field.response.request_focus(); - let frames = frames.saturating_add(1); - typing = Some((text.clone(), frames, pressed)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); - } else if field.ending.is_some() || !field.editing || !enabled { - // Esc (or the slider turning off) keeps the old value; Return or clicking away applies - let cancelled = !enabled || field.cancelled(); - if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); - } else { - typing = Some((text.clone(), frames, pressed)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); - } + if let Some(pos) = scrub { + scrub_value(ui, &g, &value_resp, pos, scrub_started, &mut out); + } else if let Some(draft) = typing.take() { + typing = edit_value(ui, &t, &g, draft, &mut out); } else if resp.double_clicked() || label_resp.double_clicked() { out.reset = true; out.value = Some(spec.default); - v = spec.default; } else { - if resp.drag_started() { - out.drag_started = true; - } - if (resp.dragged() || resp.drag_started()) - && let Some(p) = resp.interact_pointer_pos() - { - let fine = ui.input(|i| i.modifiers.shift); - let step = spec.step.max(1e-9); - let nv = if fine { - // a tenth of the pointer's movement along the track; the unrounded position is kept, so a - // slow drag adds up instead of rounding back to the same step every frame - let fine_id = id.with("fine-pos"); - let dx = ui.input(|i| i.pointer.delta().x); - let kept = ui.data(|m| m.get_temp::(fine_id)).filter(|&p| ((spec.value_at(p) / step).round() * step - value).abs() < step / 2.0); - let pos = (kept.unwrap_or_else(|| spec.position(value)) + f64::from(dx / track_rect.width()) * 0.1).clamp(0.0, 1.0); - ui.data_mut(|m| m.insert_temp(fine_id, pos)); - spec.value_at(pos) - } else { - from_x(p.x) - }; - let nv = ((nv / step).round() * step).clamp(spec.min, spec.max); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - v = nv; - } - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - // a click on the thumb leaves it there, so the first click of a double-click on it isn't a - // history step of its own before the reset - && !on_thumb(p) - { - let step = spec.step.max(1e-9); - let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max); - out.value = Some(nv); - v = nv; - out.drag_started = true; - out.drag_stopped = true; - } - if resp.drag_stopped() { - out.drag_stopped = true; - } - // a screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) - let (inc, dec) = ui.input(|i| { - i.events.iter().fold((false, false), |(inc, dec), e| match e { - egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => { - (inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement) - } - _ => (inc, dec), - }) - }); - // Arrow keys work with either a hovered or a focused slider. - if enabled && out.value.is_none() && typing.is_none() && (pointer_in_row || resp.has_focus() || inc || dec) { - let (up, down, shift) = ui.input_mut(|i| { - let shift = i.modifiers.shift; - let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; - (i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift) - }); - let (up, down) = (up || inc, down || dec); - if up || down { - let nv = nudged(spec, value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 }); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - } - // Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under - // the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it. - // Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel - // leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. - let side_id = id.with("side-rem"); - let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); - if !enabled || typing.is_some() || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() { - ui.data_mut(|m| m.remove::(side_id)); - } else { - // the vertical part would scroll the panel and move the slider out from under the pointer - ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); - let mut remainder = ui.data(|m| m.get_temp::(side_id)).unwrap_or(0.0); - let steps = wheel_nudges(&mut remainder, dx); - ui.data_mut(|m| m.insert_temp(side_id, remainder)); - let nv = nudged(spec, value, steps); - if steps != 0.0 && (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - // Wheel nudges require an explicitly selected slider, unlike regular panel scrolling. - // Consume only the vertical component so a trackpad's horizontal scroll still works. - // (`wheel_armed` is cleared above with its remainder whenever focus or the pointer leaves.) - if wheel_armed && enabled && typing.is_none() && out.value.is_none() && pointer_in_row { - let delta = ui.input_mut(|i| { - let dy = i.smooth_scroll_delta.y; - if dy != 0.0 { - i.smooth_scroll_delta.y = 0.0; - } - dy - }); - if delta != 0.0 { - let mut remainder = ui.data(|m| m.get_temp::(wheel_id)).unwrap_or(0.0); - let steps = wheel_nudges(&mut remainder, delta); - ui.data_mut(|m| m.insert_temp(wheel_id, remainder)); - if steps != 0.0 { - let nv = nudged(spec, value, steps); - if (nv - value).abs() > 1e-12 { - out.value = Some(nv); - out.drag_started = true; - out.drag_stopped = true; - v = nv; - } - } - } - } + pointer_edit(ui, &g, &resp, &mut out); + nudge_edit(ui, &g, &resp, pointer_in_row, &mut out); + side_scroll_edit(ui, &g, pointer_in_row, &mut out); + wheel_edit(ui, &g, wheel_armed, pointer_in_row, &mut out); } + let v = out.value.unwrap_or(value); // paint let hovered = resp.hovered() || resp.dragged(); let text_c = if enabled { t.text_label } else { t.text_disabled }; @@ -672,7 +465,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } } let ring = THUMB_RING; - let tx = to_x(v); + let tx = g.to_x(v); paint_track(ui, track_rect, &spec.track, &t, tx, ring); let p = ui.painter(); let ring_c = if !enabled { @@ -690,6 +483,310 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ out } +/// A value being typed into a slider: the text, the frames it has been open, and whether the press that opened it is +/// still down. +type Draft = (String, u8, bool); + +/// What a slider is, as its interaction code needs it. +struct SliderLayout<'a> { + spec: &'a ControlSpec, + value: f64, + enabled: bool, + id: egui::Id, + /// where the thumb moves + track_rect: Rect, + /// the readout, where a value is typed or scrubbed + value_rect: Rect, +} + +impl SliderLayout<'_> { + fn typing_id(&self) -> egui::Id { + self.id.with("typing") + } + + fn scrub_id(&self) -> egui::Id { + self.id.with("scrub-pos") + } + + /// The text field a value is typed in. + fn field_id(&self) -> egui::Id { + crate::text_field::id(&format!("sliderField:{}", self.spec.id)) + } + + fn step(&self) -> f64 { + self.spec.step.max(1e-9) + } + + /// `v` on the control's steps and within its range. + fn snap(&self, v: f64) -> f64 { + ((v / self.step()).round() * self.step()).clamp(self.spec.min, self.spec.max) + } + + /// Whether `nv` is a different value from the one shown. + fn differs(&self, nv: f64) -> bool { + (nv - self.value).abs() > 1e-12 + } + + /// value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) + fn to_x(&self, v: f64) -> f32 { + self.track_rect.left() + self.spec.position(v) as f32 * self.track_rect.width() + } + + fn value_at_x(&self, x: f32) -> f64 { + self.spec.value_at(f64::from((x - self.track_rect.left()) / self.track_rect.width())) + } + + /// Whether `p` is on the thumb. + fn on_thumb(&self, p: egui::Pos2) -> bool { + (p.x - self.to_x(self.value)).abs() <= THUMB_RING + 1.0 + } + + /// A change made in one go (a click, a key, a wheel step): it starts and stops the drag at once. + fn set_whole(&self, out: &mut SliderOut, nv: f64) { + out.value = Some(nv); + out.drag_started = true; + out.drag_stopped = true; + } + + /// Move to `nv` as a whole change, when it is a different value. + fn set_whole_if_changed(&self, out: &mut SliderOut, nv: f64) { + if self.differs(nv) { + self.set_whole(out, nv); + } + } +} + +/// Tell screen readers the slider's range, step and the value as shown ("+0.50", "5600"). +fn describe_slider(ui: &Ui, g: &SliderLayout, resp: &Response) { + let shown = shown_value(g.spec, g.value); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(g.spec.min); + node.set_max_numeric_value(g.spec.max); + node.set_numeric_value_step(g.spec.step); + node.set_value(shown); + if g.enabled { + node.add_action(egui::accesskit::Action::Increment); + node.add_action(egui::accesskit::Action::Decrement); + } + }); +} + +/// A press on the readout that moves sideways starts a scrub: the edit the press opened is dropped. Returns whether +/// a scrub started this frame (`scrub` then holds its first position). +fn begin_scrub(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, scrub: &mut Option, typing: &mut Option) -> bool { + if !g.enabled || scrub.is_some() || !value_resp.dragged() { + return false; + } + let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) else { return false }; + if (p.x - origin.x).abs() < SCRUB_START { + return false; + } + *typing = None; + ui.data_mut(|m| m.remove::(g.typing_id())); + ui.memory_mut(|m| m.surrender_focus(g.field_id())); + // the travel so far counts, so the value doesn't lag the pointer by the start distance + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + *scrub = Some((g.spec.position(g.value) + f64::from((p.x - origin.x) / g.track_rect.width()) * fine).clamp(0.0, 1.0)); + true +} + +/// Open the typed edit on a press or click of the readout; drop it when the press ends elsewhere. +fn update_typing_gesture(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, typing: &mut Option) { + let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); + // Focus on the owned primary press before fast typing; retain the readout's click activation. + if g.enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { + *typing = Some((shown_value(g.spec, g.value).trim_start_matches('+').to_string(), 0, value_pressed)); + } + let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + // A release over the value keeps the field the press opened, however long the button was held + // (a press held past egui's click time ends without a click). A release elsewhere cancels the + // gesture, and a press that scrubs already dropped the field above. + let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| g.value_rect.contains(p)); + if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { + *typing = None; + ui.data_mut(|m| m.remove::(g.typing_id())); + ui.memory_mut(|m| m.surrender_focus(g.field_id())); + } +} + +/// Scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel over the track's width (a +/// tenth with Shift), on the control's steps. `pos` is the unrounded track position reached so far. +fn scrub_value(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, mut pos: f64, started: bool, out: &mut SliderOut) { + if !started && value_resp.dragged() { + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + let dx = ui.input(|i| i.pointer.delta().x); + pos = (pos + f64::from(dx / g.track_rect.width()) * fine).clamp(0.0, 1.0); + } + let nv = g.snap(g.spec.value_at(pos)); + if g.differs(nv) { + out.value = Some(nv); + } + out.drag_started = started; + if value_resp.drag_stopped() { + out.drag_stopped = true; + ui.data_mut(|m| m.remove::(g.scrub_id())); + } else { + ui.data_mut(|m| m.insert_temp(g.scrub_id(), pos)); + } +} + +/// The text field over the readout while a value is typed. Returns the draft to keep, if the edit goes on. +fn edit_value(ui: &mut Ui, t: &Tokens, g: &SliderLayout, draft: Draft, out: &mut SliderOut) -> Option { + let (mut text, frames, pressed) = draft; + // Keep the owned opening release focused without restarting its draft or selection. + // egui gives that release's click to the readout, so the field under it gives up the focus + // whatever frame it lands on (issue #1362): the field takes it back after drawing too. + let released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + let opening_release = pressed && released; + if opening_release { + ui.memory_mut(|m| m.request_focus(g.field_id())); + } + let pressed = pressed && !released; + // a child over the value, so the field never moves the rows around it + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); + // the old value is selected when the field takes the focus, so typing replaces it + let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", g.spec.id), &mut text) + .font(t.font(12.5)) + .horizontal_align(egui::Align::RIGHT) + .width(g.value_rect.width()) + .margin(egui::Margin::ZERO) + .select_on_focus(true) + .begin_edit(frames == 0) + .show(&mut child); + if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { + field.response.request_focus(); + let draft = (text, frames.saturating_add(1), pressed); + ui.data_mut(|m| m.insert_temp(g.typing_id(), draft.clone())); + return Some(draft); + } + if field.ending.is_some() || !field.editing || !g.enabled { + // Esc (or the slider turning off) keeps the old value; Return or clicking away applies + let cancelled = !g.enabled || field.cancelled(); + if let Some(nv) = typed_value(g.spec, &text).filter(|nv| !cancelled && g.differs(*nv)) { + g.set_whole(out, nv); + } + ui.data_mut(|m| m.remove::(g.typing_id())); + return None; + } + let draft = (text, frames, pressed); + ui.data_mut(|m| m.insert_temp(g.typing_id(), draft.clone())); + Some(draft) +} + +/// Dragging the thumb or the track, or clicking the track. +fn pointer_edit(ui: &mut Ui, g: &SliderLayout, resp: &Response, out: &mut SliderOut) { + if resp.drag_started() { + out.drag_started = true; + } + if (resp.dragged() || resp.drag_started()) + && let Some(p) = resp.interact_pointer_pos() + { + let raw = if ui.input(|i| i.modifiers.shift) { fine_drag_value(ui, g) } else { g.value_at_x(p.x) }; + let nv = g.snap(raw); + if g.differs(nv) { + out.value = Some(nv); + } + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + // a click on the thumb leaves it there, so the first click of a double-click on it isn't a + // history step of its own before the reset + && !g.on_thumb(p) + { + g.set_whole(out, g.snap(g.value_at_x(p.x))); + } + if resp.drag_stopped() { + out.drag_stopped = true; + } +} + +/// A tenth of the pointer's movement along the track; the unrounded position is kept, so a slow drag adds up instead +/// of rounding back to the same step every frame. +fn fine_drag_value(ui: &mut Ui, g: &SliderLayout) -> f64 { + let step = g.step(); + let fine_id = g.id.with("fine-pos"); + let dx = ui.input(|i| i.pointer.delta().x); + let kept = ui.data(|m| m.get_temp::(fine_id)).filter(|&p| ((g.spec.value_at(p) / step).round() * step - g.value).abs() < step / 2.0); + let pos = (kept.unwrap_or_else(|| g.spec.position(g.value)) + f64::from(dx / g.track_rect.width()) * 0.1).clamp(0.0, 1.0); + ui.data_mut(|m| m.insert_temp(fine_id, pos)); + g.spec.value_at(pos) +} + +/// A screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) as `(increment, decrement)`. +fn accessibility_nudge(ui: &Ui, resp: &Response) -> (bool, bool) { + ui.input(|i| { + i.events.iter().fold((false, false), |(inc, dec), e| match e { + egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => { + (inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement) + } + _ => (inc, dec), + }) + }) +} + +/// The arrow keys (with either a hovered or a focused slider; Shift: five steps) and a screen reader's actions. +fn nudge_edit(ui: &mut Ui, g: &SliderLayout, resp: &Response, pointer_in_row: bool, out: &mut SliderOut) { + let (inc, dec) = accessibility_nudge(ui, resp); + if !g.enabled || out.value.is_some() || !(pointer_in_row || resp.has_focus() || inc || dec) { + return; + } + let (up, down, shift) = ui.input_mut(|i| { + let shift = i.modifiers.shift; + let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + (i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift) + }); + let (up, down) = (up || inc, down || dec); + if up || down { + let nv = nudged(g.spec, g.value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 }); + g.set_whole_if_changed(out, nv); + } +} + +/// Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under +/// the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it. +/// Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel +/// leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. +fn side_scroll_edit(ui: &mut Ui, g: &SliderLayout, pointer_in_row: bool, out: &mut SliderOut) { + let side_id = g.id.with("side-rem"); + let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); + if !g.enabled || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() { + ui.data_mut(|m| m.remove::(side_id)); + return; + } + // the vertical part would scroll the panel and move the slider out from under the pointer + ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let mut remainder = ui.data(|m| m.get_temp::(side_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, dx); + ui.data_mut(|m| m.insert_temp(side_id, remainder)); + if steps != 0.0 { + g.set_whole_if_changed(out, nudged(g.spec, g.value, steps)); + } +} + +/// Wheel nudges require an explicitly selected (armed) slider, unlike regular panel scrolling. +/// Consume only the vertical component so a trackpad's horizontal scroll still works. +fn wheel_edit(ui: &mut Ui, g: &SliderLayout, armed: bool, pointer_in_row: bool, out: &mut SliderOut) { + if !armed || !g.enabled || out.value.is_some() || !pointer_in_row { + return; + } + let delta = ui.input_mut(|i| { + let dy = i.smooth_scroll_delta.y; + if dy != 0.0 { + i.smooth_scroll_delta.y = 0.0; + } + dy + }); + if delta == 0.0 { + return; + } + let wheel_id = g.id.with("wheel-rem"); + let mut remainder = ui.data(|m| m.get_temp::(wheel_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, delta); + ui.data_mut(|m| m.insert_temp(wheel_id, remainder)); + if steps != 0.0 { + g.set_whole_if_changed(out, nudged(g.spec, g.value, steps)); + } +} + /// Convert vertical wheel movement to bounded slider steps, keeping sub-notch /// trackpad movement between frames without amplifying a single large event. One mouse-wheel /// notch ([`crate::theme::WHEEL_LINE_POINTS`]) is one step. diff --git a/crates/ui-egui/src/widgets/text_field.rs b/crates/ui-egui/src/widgets/text_field.rs index cb150080b..6b5c60c55 100644 --- a/crates/ui-egui/src/widgets/text_field.rs +++ b/crates/ui-egui/src/widgets/text_field.rs @@ -162,14 +162,10 @@ impl<'a> TextField<'a> { self } - /// Draw the field and return its response, any ending this frame, and whether an edit is - /// under way. Per-frame state keeps the pre-edit text for Esc, the selection across the - /// context menu, and select-on-focus; single-line Enter ends the edit, and focus loss ends it - /// except when the focus went to the field's own menu (a choice from the menu resumes the - /// edit). The menu offers cut/copy/paste/select-all (paste is omitted on the web; egui also - /// handles their keyboard shortcuts), and a field not drawn on the previous pass starts clean. - pub fn show(self, ui: &mut Ui) -> TextFieldResponse { - let id = id(self.widget); + /// The field's remembered state at the start of a pass: a field not drawn on the previous pass starts afresh, + /// a begun edit or a choice made from the menu last frame is carried out, and a field focused by the previous + /// pass selects its text when it asks to. + fn prepare(&self, ui: &mut Ui, id: egui::Id) -> Memo { let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default(); // a field not drawn last frame (its dialog or panel closed) starts afresh: an edit, a menu // or a choice from it that was under way is gone with it @@ -207,6 +203,19 @@ impl<'a> TextField<'a> { memo.before = Some(self.text.clone()); select_all(ui.ctx(), id, self.text); } + memo + } + + /// Draw the field and return its response, any ending this frame, and whether an edit is + /// under way. Per-frame state keeps the pre-edit text for Esc, the selection across the + /// context menu, and select-on-focus; single-line Enter ends the edit, and focus loss ends it + /// except when the focus went to the field's own menu (a choice from the menu resumes the + /// edit). The menu offers cut/copy/paste/select-all (paste is omitted on the web; egui also + /// handles their keyboard shortcuts), and a field not drawn on the previous pass starts clean. + pub fn show(self, ui: &mut Ui) -> TextFieldResponse { + let id = id(self.widget); + let pass = ui.ctx().cumulative_pass_nr(); + let mut memo = self.prepare(ui, id); let name = self.label.clone().or_else(|| self.hint.clone()); let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) }; edit = edit.id(id); diff --git a/docs/mcp.md b/docs/mcp.md index 4800c37d8..2c155e72c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -96,13 +96,13 @@ The release packages ship `lightcraft-cli` alongside the desktop app, so no buil | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\LightCraft\lightcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft\lightcraft-cli.exe` by default (`C:\Program Files\ArtCraft\LightCraft\lightcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/lightcraft-cli` | | macOS | the separate `lightcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows, default install folder -claude mcp add lightcraft -- "C:\Program Files\LightCraft\lightcraft-cli.exe" mcp +# Windows (PowerShell), default install folder +claude mcp add lightcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\LightCraft\lightcraft-cli.exe" mcp # Linux, or macOS with the CLI unzipped onto PATH claude mcp add lightcraft -- lightcraft-cli mcp ``` diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index 8327222cb..4ea8b09f5 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -11,12 +11,12 @@ Download the file for your system from the [latest release](https://github.com/s 1. Open the [latest release](https://github.com/storytold/lightcraft/releases/latest). 2. Download `lightcraft--windows-x64.msi`. It fits most PCs (Intel and AMD, 64-bit). -3. Run the file. The installer asks where to install. The default is `C:\Program Files\LightCraft`, and upgrades stay in the folder you chose. +3. Run the file. The first page asks who to install LightCraft for. **Only for me** is the default: it needs no administrator permission and installs to `%LOCALAPPDATA%\Programs\ArtCraft\LightCraft`. **Everyone on this computer** asks for an administrator and installs to `C:\Program Files\ArtCraft\LightCraft` (`Program Files (x86)` for the 32-bit build). Upgrades stay in the folder you chose. 4. At the end, choose the option to start LightCraft. For a Snapdragon or other Arm PC, download `lightcraft--windows-arm64.msi` instead. For a 32-bit Windows, download `lightcraft--windows-x86.msi`. -Upgrading from an installer older than 0.6.0 also keeps LightCraft in the folder it was installed in. If that folder was deleted, the upgrade installs to `C:\Program Files\LightCraft`. +Upgrading from an installer older than 0.6.0 also keeps LightCraft in the folder it was installed in. An upgrade uses the folder that the earlier install recorded and does not check that it still exists, so if you deleted that folder, the upgrade creates it again. To run it without installing, download `lightcraft--windows-x64-portable.zip`. Portable builds for `arm64` and `x86` are on the release page too. diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 1e4c5ed41..bae91b770 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # Build and package LightCraft for FreeBSD: # -# $DIST/lightcraft--freebsd-x86_64.tar.gz a /usr/local-style tree: -# lightcraft--freebsd-x86_64/{bin, share/applications, share/icons, share/mime, +# $DIST/lightcraft--freebsd-.tar.gz (arch: x86_64 or aarch64) a /usr/local-style tree: +# lightcraft--freebsd-/{bin, share/applications, share/icons, share/mime, # share/metainfo, share/doc/lightcraft} # # Install by copying the tree's contents into /usr/local: -# tar -xzf lightcraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local +# tar -xzf lightcraft--freebsd-.tar.gz --strip-components 1 -C /usr/local # # Usage: packaging/freebsd/package.sh [--skip-build] [--dry-run] # --dry-run stage the tree from stub binaries and list it, without building (works on any OS) @@ -29,13 +29,22 @@ while [[ $# -gt 0 ]]; do esac done +# A cross build (CI runs aarch64 in an x86_64 VM) takes its arch from the target, not the VM's uname. # FreeBSD's uname says amd64; release file names use x86_64 like the Linux and Windows ones. -MACHINE="$(uname -m)" -case "$MACHINE" in - amd64 | x86_64) ARCH=x86_64 ;; - arm64 | aarch64) ARCH=aarch64 ;; - *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; -esac +if [[ -n "${CARGO_BUILD_TARGET:-}" ]]; then + case "$CARGO_BUILD_TARGET" in + aarch64-unknown-freebsd) ARCH=aarch64 ;; + x86_64-unknown-freebsd) ARCH=x86_64 ;; + *) echo "unsupported FreeBSD target $CARGO_BUILD_TARGET" >&2; exit 2 ;; + esac +else + MACHINE="$(uname -m)" + case "$MACHINE" in + amd64 | x86_64) ARCH=x86_64 ;; + arm64 | aarch64) ARCH=aarch64 ;; + *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; + esac +fi if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 @@ -47,14 +56,15 @@ echo "==> LightCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" if [[ "$SKIP_BUILD" = 0 ]]; then - (cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif) + (cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif \ + ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"}) fi WORK="$CARGO_TARGET_DIR/freebsd-package" STAGE="$WORK/$BASENAME" rm -rf "$WORK" mkdir -p "$WORK" -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" OUT_DIR="$DIST" if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. @@ -92,7 +102,12 @@ mkdir -p "$OUT_DIR" tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" -"$STAGE/bin/lightcraft-cli" --version +# A cross-built binary (e.g. aarch64 on the x86_64 VM) cannot run on the build host. +if [[ -z "${CARGO_BUILD_TARGET:-}" ]]; then + "$STAGE/bin/lightcraft-cli" --version +else + echo "skipping --version: $CARGO_BUILD_TARGET binaries do not run on this host" +fi if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" diff --git a/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml index 752e1edec..73a44f022 100644 --- a/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml @@ -29,10 +29,10 @@ finish-args: # drag-and-drop through the document portal, so picked folders elsewhere work without broad # filesystem access. - --filesystem=xdg-pictures - # Deliberately not granted: home/host filesystem access, and network. The network is only for - # the optional SAM 3 model download (AI Object/Describe masks, docs/ai-masks.md); without it - # that download fails with an error and everything else works. Users who want it run - # `flatpak override --user --share=network ai.storyteller.lightcraft`. + # Deliberately not granted: home/host filesystem access. Network is needed for update checks + # against GitHub and the optional SAM 3 model download (AI Object/Describe masks, + # docs/ai-masks.md). + - --share=network modules: - name: lightcraft buildsystem: simple diff --git a/packaging/linux/flatpak/ai.storyteller.lightcraft.yml b/packaging/linux/flatpak/ai.storyteller.lightcraft.yml index e125475c5..83721a81c 100644 --- a/packaging/linux/flatpak/ai.storyteller.lightcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.lightcraft.yml @@ -35,10 +35,10 @@ finish-args: # drag-and-drop through the document portal, so picked folders elsewhere work without broad # filesystem access. - --filesystem=xdg-pictures - # Deliberately not granted: home/host filesystem access, and network. The network is only for - # the optional SAM 3 model download (AI Object/Describe masks, docs/ai-masks.md); without it - # that download fails with an error and everything else works. Users who want it run - # `flatpak override --user --share=network ai.storyteller.lightcraft`. + # Deliberately not granted: home/host filesystem access. Network is needed for update checks + # against GitHub and the optional SAM 3 model download (AI Object/Describe masks, + # docs/ai-masks.md). + - --share=network build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index eab3cde22..110d9ee16 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -84,6 +84,21 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped +# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked. +# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try. +stapler_retry() { + local try + for try in 1 2 3 4; do + xcrun stapler "$1" "$2" && return 0 + [[ "$try" = 4 ]] && break + echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2 + sleep $((try * 20)) + done + echo "error: stapler $1 $(basename "$2") failed 4 times" >&2 + exit 1 +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -129,8 +144,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/LightCraft-notarize.zip" notarize "$WORK/LightCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -203,8 +218,8 @@ verify_dmg_app() { verify_dmg_app if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 000000000..982a5b420 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 000000000..5463c0083 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,370 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreMachineFolder'] -and $ui['CraftRestoreMachineFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' + } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } + } + # Replay the wizard's compiled folder actions in sequence order, as the full UI runs them. A remembered + # folder must win over the new-install default, and a folder the user typed must survive both. + $uiFolderActions = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | + Where-Object { $_[0] -match '^Craft(Restore|Default)' } | Sort-Object { [int]$_[1] }) + $userDefault = (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' + foreach ($case in @( + @('1', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'E:\Remembered machine app'), + @('', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'D:\Remembered user app'), + @('1', '', '', '', $machineFolder), + @('', '', '', '', $userDefault), + @('1', 'D:\Remembered user app', 'E:\Remembered machine app', 'F:\Chosen exact app', 'F:\Chosen exact app'))) { + Set-SessionProperty $session 'Installed' '' + Set-SessionProperty $session 'ALLUSERS' $case[0] + Set-SessionProperty $session 'CRAFT_USER_DIR' $case[1] + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $case[2] + Set-SessionProperty $session 'INSTALLFOLDER' $case[3] + foreach ($row in $uiFolderActions) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + if ($folder -ine $case[4]) { throw "Full-UI folder for ALLUSERS='$($case[0])' is '$folder', expected '$($case[4])'." } + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } + # AppSearch itself never runs in this safe session, so check the compiled searches and then run the + # property actions that turn the search results into the remembered folder, once per scope. + $appSearch = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Signature_` FROM `AppSearch`' 2)) { $appSearch[$row[0]] = $row[1] } + $locators = @{} + foreach ($row in (Read-Rows $database 'SELECT `Signature_`, `Root`, `Key`, `Name`, `Type` FROM `RegLocator`' 5)) { $locators[$row[0]] = $row } + $appPaths = '^Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\[^\\]+\.exe$' + # Suffix, how to match the key, key, value name, locator type (2 = raw value, 1 = file; the 64-bit flag is masked off). + $searchContract = @( + @('DIR', 'exact', "Software\$($policy.name)", 'InstallDir', '2'), + @('FOLDER', 'exact', "Software\$($policy.name)", 'InstallFolder', '2'), + @('PATH', 'regex', $appPaths, 'Path', '2'), + @('EXEFOLDER', 'regex', $appPaths, '', '1'), + @('EXE', 'regex', $appPaths, '', '1')) + $resolverCases = @( + @{ name = 'every record, DIR wins'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Dir\' }, + @{ name = 'FOLDER wins without DIR'; dir = ''; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Folder\' }, + @{ name = 'PATH wins without DIR, FOLDER'; dir = ''; folder = ''; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Path\' }, + @{ name = 'legacy exe folder is last'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Exe\' }, + @{ name = 'legacy folder needs the exe'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = ''; existing = ''; expected = '' }, + @{ name = 'a preset folder is kept'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = 'P:\Preset\'; expected = 'P:\Preset\' }) + foreach ($scopeSpec in @(@('U', '1', 'CRAFT_USER_DIR'), @('M', '2', 'CRAFT_MACHINE_DIR'))) { + $letter = $scopeSpec[0]; $target = $scopeSpec[2] + foreach ($item in $searchContract) { + $property = "CRAFT_${letter}_$($item[0])" + $locator = if ($appSearch.ContainsKey($property)) { $locators[$appSearch[$property]] } else { $null } + $keyOk = $null -ne $locator -and $(if ($item[1] -eq 'exact') { $locator[2] -eq $item[2] } else { $locator[2] -match $item[2] }) + if (-not $keyOk -or $locator[1] -ne $scopeSpec[1] -or $locator[3] -ne $item[3] -or ([int]$locator[4] -band 3) -ne [int]$item[4]) { + throw "Compiled search for $property is missing or points at the wrong registry location." + } + } + $chain = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallExecuteSequence`' 3 | + Where-Object { $_[0] -match "^Craft${letter}From" } | Sort-Object { [int]$_[1] }) + $order = @($chain | ForEach-Object { $_[0] }) -join ',' + if ($order -ne "Craft${letter}FromDIR,Craft${letter}FromFOLDER,Craft${letter}FromPATH,Craft${letter}FromLegacyExe") { + throw "Remembered-folder resolvers for scope $letter run in the wrong order: $order" + } + foreach ($case in $resolverCases) { + Set-SessionProperty $session $target $case.existing + Set-SessionProperty $session "CRAFT_${letter}_DIR" $case.dir + Set-SessionProperty $session "CRAFT_${letter}_FOLDER" $case.folder + Set-SessionProperty $session "CRAFT_${letter}_PATH" $case.path + Set-SessionProperty $session "CRAFT_${letter}_EXEFOLDER" $case.exeFolder + Set-SessionProperty $session "CRAFT_${letter}_EXE" $case.exe + foreach ($row in $chain) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $got = Get-SessionProperty $session $target + if ($got -ne $case.expected) { throw "Resolver chain for scope $letter, case '$($case.name)': got '$got', expected '$($case.expected)'." } + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 000000000..197b2fd8f --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,35 @@ +{ + "id": "lightcraft", + "name": "LightCraft", + "upgradeCode": "229BB552-5EFD-479A-BA86-8CB6D926390C", + "sources": [ + "lightcraft.wxs" + ], + "icon": "assets/app-icon/lightcraft.ico", + "localization": [], + "extensions": [ + "UI", + "Util" + ], + "payloads": [ + "FONT-LICENCES.txt", + "NOTICE", + "lightcraft-cli.exe", + "lightcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\lightcraft.exe", + "", + "[#LightcraftExe]" + ], + [ + "-1", + "Software\\LightCraft", + "InstallFolder", + "[INSTALLFOLDER]" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 000000000..d8531ab87 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/lightcraft.wxs b/packaging/windows/lightcraft.wxs index 9e23e5430..4c5a01a2f 100644 --- a/packaging/windows/lightcraft.wxs +++ b/packaging/windows/lightcraft.wxs @@ -1,30 +1,11 @@ - - + + - + @@ -34,7 +15,9 @@ - + + + - + - + - @@ -96,8 +72,7 @@ - + @@ -108,8 +83,7 @@ event, so silent and passive installs never run it. Return="ignore": a failed launch must not turn a successful install into an error. --> - + @@ -152,35 +126,26 @@ whose OK validates too. --> - - - + + + - - + + - - + + - + - + @@ -194,13 +159,12 @@ - + - - + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 110d8b60c..9cd0da6a2 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -147,6 +147,8 @@ Invoke-Native 'wix build' { -d "UiDialogBmp=$UiDialogBmp" -d "UiBannerBmp=$UiBannerBmp" ` -o $Msi } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-install-folder.ps1 b/packaging/windows/test-install-folder.ps1 index be7733cdc..fcb8fdd10 100644 --- a/packaging/windows/test-install-folder.ps1 +++ b/packaging/windows/test-install-folder.ps1 @@ -41,7 +41,10 @@ try { $ns.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs') # Copy the real search/action authoring, changing only registry roots/keys for isolation. # Do not reimplement folder resolution here: removing the fallback must fail the legacy case. - $nodes = $source.SelectNodes('/w:Wix/w:Package/w:Property[w:RegistrySearch] | /w:Wix/w:Package/w:SetProperty[@Id="INSTALLFOLDER"]', $ns) + [xml] $scope = Get-Content (Join-Path $PSScriptRoot 'install-policy.wxi') -Raw + $scopeNs = [Xml.XmlNamespaceManager]::new($scope.NameTable) + $scopeNs.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs') + $nodes = $scope.SelectNodes('/w:Include/w:Property | /w:Include/w:SetProperty[@Sequence="both"]', $scopeNs) $authoring = foreach ($node in $nodes) { $copy = $node.CloneNode($true) foreach ($search in $copy.SelectNodes('.//w:RegistrySearch', $ns)) { @@ -50,11 +53,11 @@ try { } $copy.OuterXml } - $directory = $source.SelectSingleNode('/w:Wix/w:Package/w:StandardDirectory[w:Directory[@Id="INSTALLFOLDER"]]', $ns).OuterXml + $directory = $source.SelectSingleNode('/w:Wix/w:Package/w:StandardDirectory[.//w:Directory[@Id="INSTALLFOLDER"]]', $ns).OuterXml $probe = @" + Version="1.0.0" ProductCode="$product" UpgradeCode="$upgrade" Scope="perUserOrMachine"> $($authoring -join "`n") $directory @@ -77,7 +80,7 @@ try { New-Item -ItemType Directory -Path $legacy, $modern, $explicit, $emptied | Out-Null $legacyExe = Join-Path $legacy 'lightcraft.exe' [IO.File]::WriteAllText($legacyExe, 'test placeholder; never executed') - $programFiles = if ($Arch -eq 'x86' -and ${env:ProgramFiles(x86)}) { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + $programFiles = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'Programs\ArtCraft' $cases = @( @{ Name = 'fresh'; Expected = (Join-Path $programFiles 'LightCraft') }, @{ Name = 'legacy-app-paths'; Legacy = $legacyExe; Expected = $legacy }, diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 000000000..d455d19d1 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/sonar-project.properties b/sonar-project.properties index 717d1dfac..462b15b79 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,8 +2,11 @@ sonar.projectKey=storytold_lightcraft sonar.organization=storytold sonar.coverage.exclusions=**/tools/**,**/docs/**,**/examples/** -# Keep packaging tests analyzed as tests, outside production coverage metrics. -sonar.tests=packaging/linux/flatpak -sonar.test.inclusions=packaging/linux/flatpak/test_*.py -sonar.exclusions=packaging/linux/flatpak/test_*.py +# Setting sonar.tests without sonar.sources stops the scanner defaulting to the whole repository, +# which left only the packaging test indexed and the main branch analysed as empty. Sources and +# tests both start at the root; sonar.test.inclusions picks out the test files, so they are +# analysed as tests (outside production metrics and coverage) rather than left out. +sonar.sources=. +sonar.tests=. +sonar.test.inclusions=**/tests/**,**/tests.rs,**/tests_*.rs,**/*_tests.rs,**/test_*.py sonar.python.coverage.reportPaths=python-coverage.xml diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs index 61e179ce0..bc3a9b706 100644 --- a/xtask/src/ci.rs +++ b/xtask/src/ci.rs @@ -1,7 +1,7 @@ use super::layers::cmd_layers; use super::raw_samples::RAW_SAMPLES; use super::wasm::{cmd_wasm, cmd_wasm_for}; -use super::*; +use super::{Command, Path, PathBuf, USAGE, assets, cargo, ci_lock, ci_quick, ci_report, metadata, parity, root, run}; /// CI build jobs: one per 1.5 GB of RAM available when CI starts, at most one per CPU, 4 when it /// is unknown. With line-table debuginfo, 4 jobs measured about 3 GB of RAM in use and 2 jobs diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index 67de8c1ff..434d48b27 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -1,5 +1,5 @@ use super::raw_samples::RAW_SAMPLES; -use super::*; +use super::{Command, Path, root, run}; pub(crate) fn cmd_corpus(download: bool) -> Result<(), String> { let corpus = root().join("corpus"); diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 13756002d..2a881155b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -3,7 +3,7 @@ //! The rule engine works on a small, metadata-independent model so it can be //! unit-tested; `from_metadata` builds that model from `cargo metadata`. -use super::*; +use super::{layers, metadata}; use serde_json::Value; /// Where a workspace crate sits. diff --git a/xtask/src/wasm.rs b/xtask/src/wasm.rs index 4f16b2d75..4330769a9 100644 --- a/xtask/src/wasm.rs +++ b/xtask/src/wasm.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{cargo, layers, metadata, run}; /// Workspace packages that must build for wasm32: all L0–L5 crates plus /// the egui shell and the web app.